webhook testing

This commit is contained in:
Bilal Godil 2025-10-17 14:05:46 -07:00
parent b677e3fd74
commit c9bc00cb50
5 changed files with 390 additions and 63 deletions

View File

@ -0,0 +1,107 @@
import { getSvixClient } from "@/lib/webhooks";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { MessageStatus } from "svix";
export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
endpoint_id: yupString().defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().defined(),
error_message: yupString().optional(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const projectId = auth.tenancy.project.id;
const svix = getSvixClient();
await svix.application.getOrCreate({ uid: projectId, name: projectId });
const endpointResult = await Result.fromPromise(svix.endpoint.get(projectId, body.endpoint_id));
if (endpointResult.status === "error") {
return {
statusCode: 200,
bodyType: "json",
body: {
success: false,
error_message: "Endpoint not found. Make sure it still exists.",
},
};
}
const endpoint = endpointResult.data;
const messageResult = await Result.fromPromise(svix.message.create(
projectId,
{
eventType: "stack.test",
payload: {
type: "stack.test",
data: {
message: "Stack webhook test event triggered from the Stack dashboard.",
endpointUrl: endpoint.url,
},
},
},
));
if (messageResult.status === "error") {
const errorMessage = messageResult.error instanceof Error ? messageResult.error.message : "Unknown error while sending the test webhook.";
captureError("send-test-webhook", new StackAssertionError("Failed to send test webhook", {
cause: messageResult.error,
project_id: projectId,
endpoint_id: body.endpoint_id,
}));
return {
statusCode: 200,
bodyType: "json",
body: {
success: false,
error_message: errorMessage,
},
};
}
const attemptResult = await Result.retry(async () => {
const attempts = await svix.messageAttempt.listByMsg(
projectId,
messageResult.data.id,
{ status: MessageStatus.Success }
);
const success = attempts.data.some(a => a.endpointId === body.endpoint_id);
return success ? Result.ok(undefined) : Result.error("No successful attempt found");
}, 3);
if (attemptResult.status === "error") {
return {
statusCode: 200,
bodyType: "json",
body: {
success: false,
error_message: "Webhook not delivered. Make sure the endpoint is configured correctly.",
},
};
}
return {
statusCode: 200,
bodyType: "json",
body: {
success: true,
},
};
},
});

View File

@ -1,19 +1,22 @@
"use client";
import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
import { SmartFormDialog } from "@/components/form-dialog";
import { InputField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { SettingCard } from "@/components/settings";
import { getPublicEnvVar } from '@/lib/env';
import { urlSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui";
import { useState } from "react";
import { yupResolver } from "@hookform/resolvers/yup";
import { ActionCell, ActionDialog, Alert, AlertDescription, AlertTitle, Button, Form, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { SvixProvider, useEndpoints, useSvix } from "svix-react";
import * as yup from "yup";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { getSvixResult } from "./utils";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
type Endpoint = {
id: string,
@ -24,46 +27,134 @@ type Endpoint = {
function CreateDialog(props: {
trigger: React.ReactNode,
updateFn: () => void,
onTestRequested?: (endpoint: Endpoint) => void,
}) {
const { svix, appId } = useSvix();
const [open, setOpen] = useState(false);
const [createdEndpoint, setCreatedEndpoint] = useState<Endpoint | null>(null);
const formSchema = yup.object({
url: urlSchema.defined().label("URL"),
description: yup.string().label("Description"),
});
return <FormDialog
trigger={props.trigger}
title={"Create new endpoint"}
formSchema={formSchema}
okButton={{ label: "Create" }}
onSubmit={async (values) => {
await svix.endpoint.create(appId, { url: values.url, description: values.description });
const form = useForm<yup.InferType<typeof formSchema>>({
resolver: yupResolver(formSchema),
defaultValues: {
url: "",
description: "",
},
mode: "onChange",
});
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) {
props.updateFn();
}}
render={(form) => (
<>
<Alert>
Make sure this is a trusted URL that you control.
</Alert>
<InputField
label="URL"
name="url"
control={form.control}
/>
<InputField
label="Description"
name="description"
control={form.control}
/>
{(form.watch('url') as any)?.startsWith('http://') && (
<Alert variant="destructive">
Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments.
}
form.reset();
setCreatedEndpoint(null);
};
const handleSubmit = form.handleSubmit(async (values) => {
const created = await svix.endpoint.create(appId, { url: values.url, description: values.description });
setCreatedEndpoint({
id: created.id,
url: created.url,
description: created.description,
});
});
const allowInsecureWarning = form.watch("url").startsWith("http://");
return (
<ActionDialog
trigger={props.trigger}
title={"Create new endpoint"}
open={open}
onOpenChange={handleOpenChange}
okButton={false}
cancelButton={false}
>
{createdEndpoint ? (
<div className="space-y-6">
<Alert variant="success">
<AlertTitle>Endpoint created</AlertTitle>
<AlertDescription>
<span className="block">
<span className="font-semibold">URL:</span> {createdEndpoint.url}
</span>
{createdEndpoint.description ? (
<span className="block">
<span className="font-semibold">Description:</span> {createdEndpoint.description}
</span>
) : null}
<span className="block pt-2">
You can now send a test event to verify your integration.
</span>
</AlertDescription>
</Alert>
)}
</>
)}
/>;
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
>
Close
</Button>
<Button
onClick={() => {
props.onTestRequested?.(createdEndpoint);
handleOpenChange(false);
}}
>
Test endpoint
</Button>
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={(e) => runAsynchronously(handleSubmit(e))}
className="space-y-4"
>
<Alert>
Make sure this is a trusted URL that you control.
</Alert>
<InputField
label="URL"
name="url"
control={form.control}
/>
<InputField
label="Description"
name="description"
control={form.control}
/>
{allowInsecureWarning && (
<Alert variant="destructive">
Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments.
</Alert>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
type="button"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!form.formState.isValid}
>
Create
</Button>
</div>
</form>
</Form>
)}
</ActionDialog>
);
}
export function EndpointEditDialog(props: {
@ -120,7 +211,91 @@ function DeleteDialog(props: {
);
}
function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) {
function TestEndpointDialog(props: { endpoint: Endpoint, open: boolean, onOpenChange: (open: boolean) => void }) {
const stackAdminApp = useAdminApp();
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const previewPayload = useMemo(() => JSON.stringify({
type: "stack.test",
data: {
message: "Stack webhook test event triggered from the Stack dashboard.",
endpointUrl: props.endpoint.url,
},
}, null, 2), [props.endpoint.url]);
const resetState = () => {
setStatus('idle');
setErrorMessage(null);
};
const sendTestEvent = async () => {
setStatus('sending');
setErrorMessage(null);
const result = await stackAdminApp.sendTestWebhook({ endpointId: props.endpoint.id });
if (result.status === 'ok') {
setStatus('success');
return;
}
setStatus('error');
setErrorMessage(result.error.errorMessage);
};
return (
<ActionDialog
open={props.open}
onOpenChange={(value) => {
props.onOpenChange(value);
if (!value) {
resetState();
}
}}
title="Send a test webhook"
okButton={{
label: 'Send test event',
onClick: async () => {
await sendTestEvent();
return 'prevent-close';
},
}}
cancelButton={status === 'sending' ? false : { label: 'Cancel' }}
>
<div className="space-y-4">
<Alert>
<AlertDescription>
We&apos;ll send a simple <code className="rounded bg-muted px-1 py-0.5">stack.test</code> event to <span >{props.endpoint.url}</span> and check if it was delivered successfully.
</AlertDescription>
</Alert>
<div>
<Typography type='label'>Sample payload</Typography>
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-muted p-4 text-xs leading-relaxed">{previewPayload}</pre>
</div>
{status === 'success' && (
<Alert variant='success'>
<AlertTitle>Event sent</AlertTitle>
<AlertDescription>
Test event was sent successfully.
<br />
Your endpoint is properly configured and ready to receive events.
</AlertDescription>
</Alert>
)}
{status === 'error' && errorMessage && (
<Alert variant='destructive'>
<AlertTitle>Unable to send event</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
</div>
</ActionDialog>
);
}
function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void, onTestEndpoint: (endpoint: Endpoint) => void }) {
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const router = useRouter();
@ -144,6 +319,7 @@ function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) {
<ActionCell
items={[
{ item: "View Details", onClick: () => router.push(`/projects/${project.id}/webhooks/${props.endpoint.id}`) },
{ item: "Test", onClick: () => props.onTestEndpoint(props.endpoint) },
{ item: "Edit", onClick: () => setEditDialogOpen(true) },
'-',
{ item: "Delete", onClick: () => setDeleteDialogOpen(true), danger: true }
@ -153,41 +329,43 @@ function ActionMenu(props: { endpoint: Endpoint, updateFn: () => void }) {
);
}
function Endpoints(props: { updateFn: () => void }) {
function Endpoints(props: { updateFn: () => void, onTestRequested: (endpoint: Endpoint) => void }) {
const endpoints = getSvixResult(useEndpoints({ limit: 100 }));
if (!endpoints.loaded) {
return endpoints.rendered;
} else {
return (
<SettingCard
title="Endpoints"
description="Endpoints are the URLs that we will send events to. Please make sure you control these endpoints, as they can receive sensitive data."
actions={<CreateDialog trigger={<Button>Add new endpoint</Button>} updateFn={props.updateFn}/>}
>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[600px]">Endpoint URL</TableHead>
<TableHead className="w-[300px]">Description</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{endpoints.data.map(endpoint => (
<TableRow key={endpoint.id}>
<TableCell>{endpoint.url}</TableCell>
<TableCell>{endpoint.description}</TableCell>
<TableCell className="flex justify-end gap-4">
<ActionMenu endpoint={endpoint} updateFn={props.updateFn} />
</TableCell>
<>
<SettingCard
title="Endpoints"
description="Endpoints are the URLs that we will send events to. Please make sure you control these endpoints, as they can receive sensitive data."
actions={<CreateDialog trigger={<Button>Add new endpoint</Button>} updateFn={props.updateFn} onTestRequested={props.onTestRequested} />}
>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[600px]">Endpoint URL</TableHead>
<TableHead className="w-[300px]">Description</TableHead>
<TableHead></TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingCard>
</TableHeader>
<TableBody>
{endpoints.data.map(endpoint => (
<TableRow key={endpoint.id}>
<TableCell>{endpoint.url}</TableCell>
<TableCell>{endpoint.description}</TableCell>
<TableCell className="text-right">
<ActionMenu endpoint={endpoint} updateFn={props.updateFn} onTestEndpoint={props.onTestRequested} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingCard>
</>
);
}
}
@ -196,6 +374,7 @@ export default function PageClient() {
const stackAdminApp = useAdminApp();
const svixToken = stackAdminApp.useSvixToken();
const [updateCounter, setUpdateCounter] = useState(0);
const [testDialogEndpoint, setTestDialogEndpoint] = useState<Endpoint | null>(null);
return (
<AppEnabledGuard appId="webhooks">
@ -209,7 +388,21 @@ export default function PageClient() {
appId={stackAdminApp.projectId}
options={{ serverUrl: getPublicEnvVar('NEXT_PUBLIC_STACK_SVIX_SERVER_URL') }}
>
<Endpoints updateFn={() => setUpdateCounter(x => x + 1)} />
<Endpoints
updateFn={() => setUpdateCounter(x => x + 1)}
onTestRequested={(endpoint) => setTestDialogEndpoint(endpoint)}
/>
{testDialogEndpoint && (
<TestEndpointDialog
endpoint={testDialogEndpoint}
open
onOpenChange={(open) => {
if (!open) {
setTestDialogEndpoint(null);
}
}}
/>
)}
</SvixProvider>
</PageLayout>
</AppEnabledGuard>

View File

@ -354,6 +354,19 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
async sendTestWebhook(data: {
endpoint_id: string,
}): Promise<{ success: boolean, error_message?: string }> {
const response = await this.sendAdminRequest(`/internal/send-test-webhook`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async listSentEmails(): Promise<InternalEmailsCrud["Admin"]["List"]> {
const response = await this.sendAdminRequest("/internal/emails", {
method: "GET",

View File

@ -464,6 +464,18 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
}
}
async sendTestWebhook(options: { endpointId: string }): Promise<Result<undefined, { errorMessage: string }>> {
const response = await this._interface.sendTestWebhook({
endpoint_id: options.endpointId,
});
if (response.success) {
return Result.ok(undefined);
} else {
return Result.error({ errorMessage: response.error_message ?? throwErr("Webhook test error not specified") });
}
}
async listSentEmails(): Promise<AdminSentEmail[]> {
const response = await this._interface.listSentEmails();
return response.items.map((email) => ({

View File

@ -56,6 +56,8 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
emailConfig: EmailConfig,
}): Promise<Result<undefined, { errorMessage: string }>>,
sendTestWebhook(options: { endpointId: string }): Promise<Result<undefined, { errorMessage: string }>>,
sendSignInInvitationEmail(email: string, callbackUrl: string): Promise<void>,
listSentEmails(): Promise<AdminSentEmail[]>,