Skip to main content

Vanity Pages Implementation

This guide shows a full working pattern for embedding Wacht webhook pages inside your SaaS settings UI.

What users will get

  1. They open your Webhooks settings page.
  2. Your app fetches a short-lived access ticket from your backend.
  3. Your app embeds Wacht webhook pages in an iframe.
  4. They can manage endpoints, deliveries, replay, and secret rotation from your app.

Step 0: create your Webhook app (one-time provisioning)

Before embedding, create a webhook app for each tenant/deployment.
curl -X POST "https://api.wacht.dev/webhooks/apps" \
  -H "Authorization: Bearer <WACHT_BACKEND_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "wh_42",
    "description": "Customer webhooks for deployment 42",
    "events": []
  }'
Use a deterministic name per deployment, for example wh_<deploymentId>, so ticket issuance can target the correct app.

Full backend example (Express)

Create an endpoint in your backend that validates permissions, then issues a webhook ticket.
import express from "express";
import { WachtClient } from "@wacht/backend";

const app = express();
app.use(express.json());

const wacht = new WachtClient({ apiKey: process.env.WACHT_API_KEY! });

app.post("/api/settings/webhooks/embed-ticket", async (req, res) => {
  const { deploymentId } = req.body as { deploymentId?: string };

  // Replace with your real auth/session model.
  const user = req.user as { id: string; canManageWebhooks: boolean } | undefined;

  if (!user) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  if (!user.canManageWebhooks) {
    return res.status(403).json({ error: "Forbidden" });
  }

  if (!deploymentId) {
    return res.status(400).json({ error: "deploymentId is required" });
  }

  try {
    const ticket = await wacht.sessions.createSessionTicket({
      ticket_type: "webhook_app_access",
      webhook_app_slug: `wh_${deploymentId}`,
      expires_in: 120,
    });

    return res.json({
      ticket: ticket.ticket,
      expires_at: ticket.expires_at,
    });
  } catch (error) {
    return res.status(500).json({ error: "Failed to create webhook ticket" });
  }
});

Full frontend example (React)

This example supports /settings/webhooks, /settings/webhooks/endpoints, and /settings/webhooks/deliveries.
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { useDeployment } from "@wacht/react-router";

type EmbedResponse = {
  ticket: string;
  expires_at: number;
};

function mapToVanityWebhookPath(pathname: string): string {
  const suffix = pathname.replace(/^\/settings\/webhooks/, "");
  return `/webhook${suffix || ""}`;
}

export function WebhooksPage({ deploymentId }: { deploymentId: string }) {
  const { deployment } = useDeployment();
  const { pathname } = useLocation();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [ticket, setTicket] = useState<string | null>(null);
  const [retryNonce, setRetryNonce] = useState(0);

  useEffect(() => {
    let cancelled = false;

    const run = async () => {
      setLoading(true);
      setError(null);
      setTicket(null);
      if (!deployment?.backend_host) return;

      try {
        const response = await fetch("/api/settings/webhooks/embed-ticket", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ deploymentId }),
        });

        if (!response.ok) {
          throw new Error("Could not start webhook session");
        }

        const data = (await response.json()) as EmbedResponse;
        if (cancelled) return;

        setTicket(data.ticket);
      } catch (e) {
        if (cancelled) return;
        setError(e instanceof Error ? e.message : "Unknown error");
      } finally {
        if (!cancelled) setLoading(false);
      }
    };

    void run();
    return () => {
      cancelled = true;
    };
  }, [deploymentId, retryNonce, deployment?.backend_host]);

  const iframeSrc = useMemo(() => {
    const vanityBaseUrl = deployment?.backend_host
      ? `${deployment.backend_host}/vanity`
      : null;
    if (!ticket || !vanityBaseUrl) return null;
    const vanityPath = mapToVanityWebhookPath(pathname);
    return `${vanityBaseUrl}${vanityPath}?ticket=${encodeURIComponent(ticket)}`;
  }, [ticket, deployment?.backend_host, pathname]);

  if (loading) return <div>Loading webhook manager...</div>;

  if (error) {
    return (
      <div>
        <p>{error}</p>
        <button onClick={() => setRetryNonce((n) => n + 1)}>Retry</button>
      </div>
    );
  }

  if (!iframeSrc) return <div>Unable to initialize webhook manager.</div>;

  return (
    <div style={{ height: "calc(100vh - 120px)", width: "100%" }}>
      <iframe
        src={iframeSrc}
        title="Webhook"
        style={{ border: 0, width: "100%", height: "100%" }}
        allow="clipboard-read; clipboard-write"
      />
    </div>
  );
}

Route wiring example (React Router)

import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { WebhooksPage } from "./WebhooksPage";

const router = createBrowserRouter([
  { path: "/settings/webhooks", element: <WebhooksPage deploymentId="42" /> },
  { path: "/settings/webhooks/endpoints", element: <WebhooksPage deploymentId="42" /> },
  { path: "/settings/webhooks/deliveries", element: <WebhooksPage deploymentId="42" /> },
]);

export function App() {
  return <RouterProvider router={router} />;
}

Security requirements

  1. Issue tickets only from backend.
  2. Enforce RBAC before ticket creation.
  3. Keep expiry short (for example 120 seconds).
  4. Validate tenant ownership before using wh_<deploymentId>.

Where backend_host comes from

In the real console flow, iframe base URL is derived from deployment context:
const { deployment } = useDeployment();
const vanityBaseUrl = deployment?.backend_host
  ? `${deployment.backend_host}/vanity`
  : null;

SDK + API references

  1. React Router useWebhookAppSession
  2. React Router useWebhookEndpoints
  3. React Router useWebhookDeliveries
  4. React Router useWebhookAnalytics
  5. React Router useWebhookTimeseries
  6. Webhooks Backend API Reference

Verification checklist

  1. Unauthorized user gets 403 from ticket endpoint.
  2. Expired ticket cannot be reused.
  3. Endpoint CRUD and test flow work inside iframe.
  4. Delivery replay works and task status updates appear.