Jun 18, 2025

Building an Outbound Webhook System with Hookdeck & Encore.ts

Add reliable webhook notifications to your SaaS with Hookdeck Event Gateway and real-time testing

20 Min Read

Building an Outbound Webhook System with Hookdeck Event Gateway

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.

Want to build this faster? Try using Leap to generate this entire application with a simple prompt like "Build an outbound webhook system with Hookdeck Event Gateway integration." Leap can scaffold the complete backend and frontend code, letting you focus on customization rather than boilerplate.

This is what the end result should look like:

A modern webhook management dashboard showing:

  • List of configured webhook endpoints with their status
  • Real-time webhook testing and event triggering
  • Event monitoring and delivery logs
  • Integration with Hookdeck's reliable delivery infrastructure

Getting Started

To get started, you'll need Encore installed on your machine:

macOS
Windows
Linux
Brew
$ brew install encoredev/tap/encore

Once that's ready, we can create our project:

encore app create webhook-system --example=hello-world

Backend Implementation

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.

1. Setting Up the Webhook Service

Let's create a dedicated webhook service that will be responsible for:

  • Managing webhook endpoints that users register with your platform
  • Using Hookdeck Event Gateway to reliably deliver webhook notifications
  • Providing webhook testing capabilities for immediate feedback
  • Tracking webhook events and delivery status

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.

2. Database Setup

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", });

3. Setting Up Hookdeck Integration

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.

4. Defining Data Types

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; }

5. Implementing Webhook Management APIs

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} `; } );

6. Webhook Testing API

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"); } } );

Setting Up Hookdeck Integration

1. Getting Your Hookdeck API Key

  1. Go to Hookdeck and create a free account
  2. Complete the email verification process
  3. Once logged in, navigate to SettingsAPI Keys
  4. Create a new API key or copy your existing one
  5. The API key will look like hkdk_...

2. Configuring Secrets

Set your Hookdeck API key in Encore:

encore secret set HookdeckApiKey # Paste your Hookdeck API key when prompted

Frontend Implementation

Now let's build a React frontend that provides an intuitive interface for managing webhook endpoints and testing webhook delivery.

1. Main App Component

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> ); }

2. Subscription List Component

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> ); }

3. Create Subscription Form

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> ); }

4. Test Webhook Form

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> ); }

Running the Application

Now that we've built both the backend and frontend, let's bring everything together and see our outbound webhook system in action.

Starting the Development Environment

Launch your Encore application with a single command:

encore run

This starts both your backend services and the local development environment. Encore automatically handles:

  • Database creation and migration: Your PostgreSQL database will be created and the schema applied
  • Service discovery: All your APIs are automatically registered and made available
  • Hot reloading: Changes to your backend code will automatically restart the affected services
  • Request routing: Incoming requests are routed to the correct service endpoints

Accessing Your Application

Once the server is running, your webhook management application will be available at multiple endpoints:

The development dashboard provides you with:

  • API Explorer: Test your endpoints directly in the browser
  • Request Tracing: See detailed traces of every API call
  • Database Inspector: Query your database and view schema
  • Logs: Real-time logs from all your services
  • Architecture Diagram: Visual representation of your application structure

Testing Your Webhook System

Here's how to verify everything is working:

  1. Navigate to the application at http://localhost:4000
  2. Add your first webhook endpoint by clicking "Add Endpoint"
  3. Use webhook.site for testing - go to webhook.site and copy the unique URL
  4. Fill in the endpoint form with a name and the webhook.site URL
  5. Verify the endpoint appears in your dashboard
  6. Test webhook delivery by clicking the "Test Event" tab and sending a test event
  7. Check webhook.site to see the delivered webhook payload

Deployment

Once your application is working locally, you can deploy it to production.

Deploying to Encore Cloud

Deploy your application using Git:

git add . git commit -m "Initial outbound webhook system" git push encore

This triggers an automatic deployment that:

  1. Builds your application in Encore's cloud infrastructure
  2. Provisions resources including PostgreSQL database, compute instances, and networking
  3. Deploys your services with automatic scaling and load balancing
  4. Sets up monitoring with distributed tracing and metrics
  5. Provides a production URL for your live application

Managing Secrets in Production

Your Hookdeck API key needs to be set in the production environment:

# Set secrets for your production environment encore secret set --env=production HookdeckApiKey

Next Steps

Your outbound webhook system is now live! Here are some ideas for extending it:

  • Event filtering and routing: Add rules to send different events to different endpoints
  • Webhook authentication: Add signature verification for secure webhook delivery
  • Rate limiting: Implement rate limiting to prevent webhook spam
  • Analytics dashboard: Track delivery rates and endpoint health
  • Bulk event sending: Add capabilities to send events to multiple endpoints at once

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.

Encore

This blog is presented by Encore, the backend framework for building robust type-safe distributed systems with declarative infrastructure.

Like this article?
Get future ones straight to your mailbox.

You can unsubscribe at any time.