Repository: thirdweb-dev/js
Pull request #6779: [Dashboard] Add webhook version support to Universal Bridge
Author: login: joaquim-verges
Labels: name: Dashboard
description: Involves changes to the Dashboard.
Created: 2025-04-20T08:25:44Z | Merged: 2025-04-22T00:46:40Z

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the webhook functionality and fee configuration in the `universal-bridge` section of the dashboard. It introduces new API integrations for managing webhooks and developer fees, while streamlining the related UI components.

### Detailed summary
- Removed unnecessary props from `<PayWebhooksPage />` in `page.tsx`.
- Added `developerFeeBPS` validation in `validations.ts`.
- Integrated `getFees` API call in `page.tsx` to fetch fee details.
- Updated `<PayConfig />` to include `fees` prop and modified form handling for fees.
- Added new API functions for managing webhooks in `developer.ts`.
- Updated `<PayWebhooksPage />` to use `getWebhooks` instead of deprecated server proxy.
- Enhanced webhook creation UI to include version selection and secret key management.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->


Diff:
diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts
new file mode 100644
index 00000000000..d5d918071c8
--- /dev/null
+++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts
@@ -0,0 +1,148 @@
+"use server";
+import { getAuthToken } from "app/api/lib/getAuthToken";
+
+const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
+
+type Webhook = {
+  url: string;
+  label: string;
+  active: boolean;
+  createdAt: string;
+  id: string;
+  secret: string;
+  version?: number; // TODO (UB) make this mandatory after migration
+};
+
+export async function getWebhooks(props: {
+  clientId: string;
+}) {
+  const authToken = await getAuthToken();
+  const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+      "x-client-id-override": props.clientId,
+      Authorization: `Bearer ${authToken}`,
+    },
+  });
+
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(text);
+  }
+
+  const json = await res.json();
+  return json.data as Array<Webhook>;
+}
+
+export async function createWebhook(props: {
+  clientId: string;
+  version?: number;
+  url: string;
+  label: string;
+  secret?: string;
+}) {
+  const authToken = await getAuthToken();
+  const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
+    method: "POST",
+    body: JSON.stringify({
+      url: props.url,
+      label: props.label,
+      version: props.version,
+      secret: props.secret,
+    }),
+    headers: {
+      "Content-Type": "application/json",
+      "x-client-id-override": props.clientId,
+      Authorization: `Bearer ${authToken}`,
+    },
+  });
+
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(text);
+  }
+
+  return;
+}
+
+export async function deleteWebhook(props: {
+  clientId: string;
+  webhookId: string;
+}) {
+  const authToken = await getAuthToken();
+  const res = await fetch(
+    `${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`,
+    {
+      method: "DELETE",
+      headers: {
+        "Content-Type": "application/json",
+        "x-client-id-override": props.clientId,
+        Authorization: `Bearer ${authToken}`,
+      },
+    },
+  );
+
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(text);
+  }
+
+  return;
+}
+
+export type Fee = {
+  feeRecipient: string;
+  feeBps: number;
+  createdAt: string;
+  updatedAt: string;
+};
+
+export async function getFees(props: {
+  clientId: string;
+}) {
+  const authToken = await getAuthToken();
+  const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+      "x-client-id-override": props.clientId,
+      Authorization: `Bearer ${authToken}`,
+    },
+  });
+
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(text);
+  }
+
+  const json = await res.json();
+  return json.data as Fee;
+}
+
+export async function updateFee(props: {
+  clientId: string;
+  feeRecipient: string;
+  feeBps: number;
+}) {
+  const authToken = await getAuthToken();
+  const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
+    method: "PUT",
+    headers: {
+      "Content-Type": "application/json",
+      "x-client-id-override": props.clientId,
+      Authorization: `Bearer ${authToken}`,
+    },
+    body: JSON.stringify({
+      feeRecipient: props.feeRecipient,
+      feeBps: props.feeBps,
+    }),
+  });
+
+  if (!res.ok) {
+    const text = await res.text();
+    throw new Error(text);
+  }
+
+  return;
+}
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/settings/page.tsx
index 1b7b793aba7..a0314e44909 100644
--- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/settings/page.tsx
+++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/settings/page.tsx
@@ -1,5 +1,6 @@
 import { getProject } from "@/api/projects";
 import { getTeamBySlug } from "@/api/team";
+import { getFees } from "@/api/universal-bridge/developer";
 import { redirect } from "next/navigation";
 import { PayConfig } from "../../../../../../../components/pay/PayConfig";
 
@@ -24,5 +25,32 @@ export default async function Page(props: {
     redirect(`/team/${team_slug}`);
   }
 
-  return <PayConfig project={project} teamId={team.id} teamSlug={team_slug} />;
+  let fees = await getFees({
+    clientId: project.publishableKey,
+  }).catch(() => {
+    return {
+      feeRecipient: "",
+      feeBps: 0,
+      createdAt: "",
+      updatedAt: "",
+    };
+  });
+
+  if (!fees) {
+    fees = {
+      feeRecipient: "",
+      feeBps: 0,
+      createdAt: "",
+      updatedAt: "",
+    };
+  }
+
+  return (
+    <PayConfig
+      project={project}
+      teamId={team.id}
+      teamSlug={team_slug}
+      fees={fees}
+    />
+  );
 }
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/components/webhooks.client.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/components/webhooks.client.tsx
index 357c7d508e2..b4d1ef499db 100644
--- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/components/webhooks.client.tsx
+++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/components/webhooks.client.tsx
@@ -1,10 +1,15 @@
 "use client";
 
-import { payServerProxy } from "@/actions/proxies";
+import {
+  createWebhook,
+  deleteWebhook,
+  getWebhooks,
+} from "@/api/universal-bridge/developer";
 import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
 import { CopyTextButton } from "@/components/ui/CopyTextButton";
 import { Spinner } from "@/components/ui/Spinner/Spinner";
 import { Button } from "@/components/ui/button";
+import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
 import {
   Dialog,
   DialogContent,
@@ -44,58 +49,24 @@ import { zodResolver } from "@hookform/resolvers/zod";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { formatDistanceToNow } from "date-fns";
 import { PlusIcon, TrashIcon } from "lucide-react";
-import { type PropsWithChildren, useState } from "react";
+import { type PropsWithChildren, useMemo, useState } from "react";
 import { useForm } from "react-hook-form";
 import { toast } from "sonner";
+import { randomPrivateKey } from "thirdweb/wallets";
 import { shortenString } from "utils/usedapp-external";
 import { z } from "zod";
 
-type Webhook = {
-  url: string;
-  label: string;
-  active: boolean;
-  createdAt: string;
-  id: string;
-  secret: string;
-};
-
 type PayWebhooksPageProps = {
-  /**
-   *  @deprecated - remove after migration
-   */
   clientId: string;
-  // switching to projectId for lookup, but have to send both during migration
-  projectId: string;
-  teamId: string;
 };
 
 export function PayWebhooksPage(props: PayWebhooksPageProps) {
   const webhooksQuery = useQuery({
     queryKey: ["webhooks", props.clientId],
     queryFn: async () => {
-      const res = await payServerProxy({
-        method: "GET",
-        pathname: "/webhooks/get-all",
-        searchParams: {
-          /**
-           *  @deprecated - remove after migration
-           */
-          clientId: props.clientId,
-          // switching to projectId for lookup, but have to send both during migration
-          projectId: props.projectId,
-          teamId: props.teamId,
-        },
-        headers: {
-          "Content-Type": "application/json",
-        },
+      return await getWebhooks({
+        clientId: props.clientId,
       });
-
-      if (!res.ok) {
-        throw new Error();
-      }
-
-      const json = res.data as { result: Array<Webhook> };
-      return json.result;
     },
   });
 
@@ -107,11 +78,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
     return (
       <div className="flex flex-col items-center gap-8 rounded-lg border border-border p-8 text-center">
         <h2 className="font-semibold text-xl">No webhooks configured yet.</h2>
-        <CreateWebhookButton
-          clientId={props.clientId}
-          projectId={props.projectId}
-          teamId={props.teamId}
-        >
+        <CreateWebhookButton clientId={props.clientId}>
           <Button variant="primary" className="gap-1">
             <PlusIcon className="size-4" />
             <span>Create Webhook</span>
@@ -125,11 +92,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
     <div>
       <div className="flex items-center justify-between">
         <h2 className="font-semibold text-xl tracking-tight">Webhooks</h2>
-        <CreateWebhookButton
-          clientId={props.clientId}
-          projectId={props.projectId}
-          teamId={props.teamId}
-        >
+        <CreateWebhookButton clientId={props.clientId}>
           <Button size="sm" variant="default" className="gap-1">
             <PlusIcon className="size-4" />
             <span>Create Webhook</span>
@@ -145,8 +108,8 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
             <TableRow>
               <TableHead>Label</TableHead>
               <TableHead>Url</TableHead>
-              <TableHead>Secret</TableHead>
               <TableHead>Created</TableHead>
+              <TableHead>Version</TableHead>
               <TableHead>Delete</TableHead>
             </TableRow>
           </TableHeader>
@@ -155,23 +118,14 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
               <TableRow key={webhook.id}>
                 <TableCell className="font-medium">{webhook.label}</TableCell>
                 <TableCell>{webhook.url}</TableCell>
-                <TableCell>
-                  <CopyTextButton
-                    textToShow={shortenString(webhook.secret)}
-                    textToCopy={webhook.secret}
-                    tooltip="Use this secret to validate the authenticity of incoming webhook requests."
-                    copyIconPosition="right"
-                  />
-                </TableCell>
                 <TableCell>
                   {formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
                 </TableCell>
+                <TableCell>{webhook.version || "v1"}</TableCell>
                 <TableCell className="text-right">
                   <DeleteWebhookButton
                     clientId={props.clientId}
-                    projectId={props.projectId}
                     webhookId={webhook.id}
-                    teamId={props.teamId}
                   >
                     <Button variant="ghost" size="icon">
                       <TrashIcon className="size-5" strokeWidth={1} />
@@ -190,44 +144,37 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
 const formSchema = z.object({
   url: z.string().url("Please enter a valid URL."),
   label: z.string().min(3, "Label must be at least 3 characters long"),
+  version: z.string(),
+  secret: z.string().optional(),
 });
 
 function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
   const [open, setOpen] = useState(false);
+  const [secretStored, setSecretStored] = useState(false);
+  const secret = useMemo(() => {
+    if (!open) return "";
+    return randomPrivateKey();
+  }, [open]);
+
   const form = useForm<z.infer<typeof formSchema>>({
     resolver: zodResolver(formSchema),
     defaultValues: {
       url: "",
       label: "",
+      version: "2",
     },
   });
   const queryClient = useQueryClient();
   const createMutation = useMutation({
     mutationFn: async (values: z.infer<typeof formSchema>) => {
-      const res = await payServerProxy({
-        method: "POST",
-        pathname: "/webhooks/create",
-        body: JSON.stringify({
-          ...values,
-          /**
-           *  @deprecated - remove after migration
-           */
-          clientId: props.clientId,
-          // switching to projectId for lookup, but have to send both during migration
-          projectId: props.projectId,
-          teamId: props.teamId,
-        }),
-        headers: {
-          "Content-Type": "application/json",
-        },
+      await createWebhook({
+        clientId: props.clientId,
+        url: values.url,
+        label: values.label,
+        version: Number(values.version),
+        secret,
       });
-
-      if (!res.ok) {
-        throw new Error(res.error);
-      }
-
-      const json = res.data as { result: string };
-      return json.result;
+      return null;
     },
     onSuccess: () => {
       return queryClient.invalidateQueries({
@@ -250,11 +197,14 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
                 },
                 onSuccess: () => {
                   setOpen(false);
+                  setSecretStored(false);
                   toast.success("Webhook created successfully");
                   form.reset();
                   form.clearErrors();
                   form.setValue("url", "");
                   form.setValue("label", "");
+                  form.setValue("version", "2");
+                  form.setValue("secret", undefined);
                 },
               }),
             )}
@@ -263,7 +213,8 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
             <DialogHeader>
               <DialogTitle>Create Webhook</DialogTitle>
               <DialogDescription>
-                Receive a webhook notification when a pay event occurs.
+                Receive a webhook notification when a bridge, swap or onramp
+                event occurs.
               </DialogDescription>
             </DialogHeader>
 
@@ -294,29 +245,58 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
               )}
             />
 
-            {/* Note: this is a "fake" form field since there is nothing to select yet */}
-            <FormItem>
-              <FormLabel>Event Type</FormLabel>
-              <Select disabled defaultValue="purchase_complete">
-                <SelectTrigger className="w-full">
-                  <SelectValue placeholder="Purchase Complete" />
-                </SelectTrigger>
-                <SelectContent>
-                  <SelectItem value="purchase_complete">
-                    Purchase Complete
-                  </SelectItem>
-                </SelectContent>
-              </Select>
+            <FormField
+              name="version"
+              render={({ field }) => (
+                <FormItem>
+                  <FormLabel>Version</FormLabel>
+                  <Select {...field} onValueChange={field.onChange}>
+                    <SelectTrigger className="w-full">
+                      <SelectValue placeholder="v2" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="2">v2</SelectItem>
+                      <SelectItem value="1">v1</SelectItem>
+                    </SelectContent>
+                  </Select>
+                  <FormDescription>
+                    Select the data format of the webhook payload (v2
+                    recommended, v1 for legacy users).
+                  </FormDescription>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+
+            <section>
+              <FormLabel>Secret Key</FormLabel>
+
+              <CopyTextButton
+                textToCopy={secret}
+                className="!h-auto my-1 w-full justify-between truncate bg-card px-3 py-3 font-mono"
+                textToShow={shortenString(secret)}
+                copyIconPosition="right"
+                tooltip="Copy Secret Key"
+              />
               <FormDescription>
-                Which event should trigger this webhook?
+                Passed as a bearer token in all webhook requests to verify the
+                authenticity of the request.
               </FormDescription>
-              <FormMessage />
-            </FormItem>
+              <CheckboxWithLabel className="my-2 text-foreground">
+                <Checkbox
+                  checked={secretStored}
+                  onCheckedChange={(v) => {
+                    setSecretStored(!!v);
+                  }}
+                />
+                I confirm that I've securely stored my secret key
+              </CheckboxWithLabel>
+            </section>
 
             <DialogFooter>
               <Button
                 type="submit"
-                disabled={createMutation.isPending}
+                disabled={createMutation.isPending || !secretStored}
                 className="gap-2"
               >
                 Create Webhook
@@ -337,30 +317,11 @@ function DeleteWebhookButton(
   const queryClient = useQueryClient();
   const deleteMutation = useMutation({
     mutationFn: async (id: string) => {
-      const res = await payServerProxy({
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({
-          id,
-          /**
-           *  @deprecated - remove after migration
-           */
-          clientId: props.clientId,
-          // switching to projectId for lookup, but have to send both during migration
-          projectId: props.projectId,
-          teamId: props.teamId,
-        }),
-        pathname: "/webhooks/revoke",
+      await deleteWebhook({
+        clientId: props.clientId,
+        webhookId: id,
       });
-
-      if (!res.ok) {
-        throw new Error("Failed to delete webhook");
-      }
-
-      const json = res.data as { result: string };
-      return json.result;
+      return null;
     },
     onSuccess: () => {
       return queryClient.invalidateQueries({
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/page.tsx
index 824f6310ff3..c6492d6195d 100644
--- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/page.tsx
+++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/page.tsx
@@ -15,11 +15,5 @@ export default async function Page(props: {
     redirect(`/team/${params.team_slug}`);
   }
 
-  return (
-    <PayWebhooksPage
-      clientId={project.publishableKey}
-      projectId={project.id}
-      teamId={project.teamId}
-    />
-  );
+  return <PayWebhooksPage clientId={project.publishableKey} />;
 }
diff --git a/apps/dashboard/src/components/pay/PayConfig.tsx b/apps/dashboard/src/components/pay/PayConfig.tsx
index 069f3caca7e..dfddfb6adb5 100644
--- a/apps/dashboard/src/components/pay/PayConfig.tsx
+++ b/apps/dashboard/src/components/pay/PayConfig.tsx
@@ -1,9 +1,8 @@
 "use client";
 
 import type { Project } from "@/api/projects";
+import { type Fee, updateFee } from "@/api/universal-bridge/developer";
 import { SettingsCard } from "@/components/blocks/SettingsCard";
-import { UnderlineLink } from "@/components/ui/UnderlineLink";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
 import {
   Form,
   FormControl,
@@ -12,7 +11,6 @@ import {
   FormLabel,
 } from "@/components/ui/form";
 import { Input } from "@/components/ui/input";
-import { updateProjectClient } from "@3rdweb-sdk/react/hooks/useApi";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { useMutation } from "@tanstack/react-query";
 import {
@@ -20,7 +18,6 @@ import {
   apiKeyPayConfigValidationSchema,
 } from "components/settings/ApiKeys/validations";
 import { useTrack } from "hooks/analytics/useTrack";
-import { CircleAlertIcon } from "lucide-react";
 import Link from "next/link";
 import { useForm } from "react-hook-form";
 import { toast } from "sonner";
@@ -29,97 +26,71 @@ interface PayConfigProps {
   project: Project;
   teamId: string;
   teamSlug: string;
+  fees: Fee;
 }
 
 const TRACKING_CATEGORY = "pay";
 
 export const PayConfig: React.FC<PayConfigProps> = (props) => {
-  const payService = props.project.services.find(
-    (service) => service.name === "pay",
-  );
-
   const form = useForm<ApiKeyPayConfigValidationSchema>({
     resolver: zodResolver(apiKeyPayConfigValidationSchema),
     values: {
-      payoutAddress: payService?.payoutAddress ?? "",
+      payoutAddress: props.fees.feeRecipient ?? "",
+      developerFeeBPS: props.fees.feeBps ? props.fees.feeBps / 100 : 0,
     },
   });
 
   const trackEvent = useTrack();
 
-  const updateProject = useMutation({
-    mutationFn: async (projectValues: Partial<Project>) => {
-      await updateProjectClient(
-        {
-          projectId: props.project.id,
-          teamId: props.teamId,
-        },
-        projectValues,
-      );
+  const updateFeeMutation = useMutation({
+    mutationFn: async (values: {
+      payoutAddress: string;
+      developerFeeBPS: number;
+    }) => {
+      await updateFee({
+        clientId: props.project.publishableKey,
+        feeRecipient: values.payoutAddress,
+        feeBps: values.developerFeeBPS,
+      });
     },
   });
 
-  const handleSubmit = form.handleSubmit(({ payoutAddress }) => {
-    const services = props.project.services;
-
-    const newServices = services.map((service) => {
-      if (service.name !== "pay") {
-        return service;
-      }
-
-      return {
-        ...service,
-        payoutAddress,
-      };
-    });
-
-    updateProject.mutate(
-      {
-        services: newServices,
-      },
-      {
-        onSuccess: () => {
-          toast.success("Fee sharing updated");
-          trackEvent({
-            category: TRACKING_CATEGORY,
-            action: "configuration-update",
-            label: "success",
-            data: {
-              payoutAddress,
-            },
-          });
+  const handleSubmit = form.handleSubmit(
+    ({ payoutAddress, developerFeeBPS }) => {
+      updateFeeMutation.mutate(
+        {
+          payoutAddress,
+          developerFeeBPS: developerFeeBPS ? developerFeeBPS * 100 : 0,
         },
-        onError: (err) => {
-          toast.error("Failed to update fee sharing");
-          console.error(err);
-          trackEvent({
-            category: TRACKING_CATEGORY,
-            action: "configuration-update",
-            label: "error",
-            error: err,
-          });
+        {
+          onSuccess: () => {
+            toast.success("Fee sharing updated");
+            trackEvent({
+              category: TRACKING_CATEGORY,
+              action: "configuration-update",
+              label: "success",
+              data: {
+                payoutAddress,
+              },
+            });
+          },
+          onError: (err) => {
+            toast.error("Failed to update fee sharing");
+            console.error(err);
+            trackEvent({
+              category: TRACKING_CATEGORY,
+              action: "configuration-update",
+              label: "error",
+              error: err,
+            });
+          },
         },
-      },
-    );
-  });
-
-  if (!payService) {
-    return (
-      <Alert variant="warning">
-        <CircleAlertIcon className="size-5" />
-        <AlertTitle>Pay service is disabled</AlertTitle>
-        <AlertDescription>
-          Enable Pay service in{" "}
-          <UnderlineLink
-            href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
-          >
-            project settings
-          </UnderlineLink>{" "}
-          to configure settings
-        </AlertDescription>
-      </Alert>
-    );
-  }
+      );
+    },
+    (errors) => {
+      console.log(errors);
+    },
+  );
 
   return (
     <Form {...form}>
@@ -130,7 +101,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
           saveButton={{
             type: "submit",
             disabled: !form.formState.isDirty,
-            isPending: updateProject.isPending,
+            isPending: updateFeeMutation.isPending,
           }}
           noPermissionText={undefined}
         >
@@ -140,7 +111,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
             </h3>
             <p className="mt-1.5 mb-4 text-foreground text-sm">
               thirdweb collects a 0.3% protocol fee on swap transactions. You
-              may set your own developer fee in addition to this fee.
+              may set your own developer fee in addition to this fee.{" "}
               <Link
                 href="https://portal.thirdweb.com/connect/pay/fee-sharing"
                 target="_blank"
@@ -150,18 +121,35 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
               </Link>
             </p>
 
-            <FormField
-              control={form.control}
-              name="payoutAddress"
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel>Recipient address</FormLabel>
-                  <FormControl>
-                    <Input {...field} placeholder="0x..." />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
+            <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
+              <FormField
+                control={form.control}
+                name="payoutAddress"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>Recipient address</FormLabel>
+                    <FormControl>
+                      <Input {...field} placeholder="0x..." />
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+              <FormField
+                control={form.control}
+                name="developerFeeBPS"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>Fee amount</FormLabel>
+                    <FormControl>
+                      <div className="flex items-center gap-2">
+                        <Input {...field} type="number" placeholder="0.5" />
+                        <span className="text-muted-foreground text-sm">%</span>
+                      </div>
+                    </FormControl>
+                  </FormItem>
+                )}
+              />
+            </div>
           </div>
         </SettingsCard>
       </form>
diff --git a/apps/dashboard/src/components/settings/ApiKeys/validations.ts b/apps/dashboard/src/components/settings/ApiKeys/validations.ts
index 49b524180cc..9ce385b6e3e 100644
--- a/apps/dashboard/src/components/settings/ApiKeys/validations.ts
+++ b/apps/dashboard/src/components/settings/ApiKeys/validations.ts
@@ -114,6 +114,13 @@ export const apiKeyEmbeddedWalletsValidationSchema = z.object({
 
 export const apiKeyPayConfigValidationSchema = z.object({
   payoutAddress: payoutAddressValidation,
+  developerFeeBPS: z
+    .string()
+    .transform((val) => Number(val))
+    .refine((val) => val >= 0 && val <= 100, {
+      message: "Developer fee must be between 0 and 100",
+    })
+    .optional(),
 });
 
 export type ApiKeyEmbeddedWalletsValidationSchema = z.infer<


Reviews:
user: login: jnsdls
body: just a small nit
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-21T18:30:01Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:17:20Z

Review comments:
diff_hunk: @@ -294,21 +298,20 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
               )}
             />
 
-            {/* Note: this is a "fake" form field since there is nothing to select yet */}
             <FormItem>
-              <FormLabel>Event Type</FormLabel>
-              <Select disabled defaultValue="purchase_complete">
+              <FormLabel>Webhook Version</FormLabel>
+              <Select defaultValue="v2">
                 <SelectTrigger className="w-full">
-                  <SelectValue placeholder="Purchase Complete" />
+                  <SelectValue placeholder="v2" />
                 </SelectTrigger>
                 <SelectContent>
-                  <SelectItem value="purchase_complete">
-                    Purchase Complete
-                  </SelectItem>
+                  <SelectItem value="v2">v2</SelectItem>
+                  <SelectItem value="v1">v1</SelectItem>
path: apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/universal-bridge/webhooks/components/webhooks.client.tsx
user: login: jnsdls
body: nit, if the field is called `version` then having `v` in the version feels odd
created_at: 2025-04-21T18:29:56Z
author_association: MEMBER
diff_hunk: @@ -114,6 +114,13 @@ export const apiKeyEmbeddedWalletsValidationSchema = z.object({
 
 export const apiKeyPayConfigValidationSchema = z.object({
   payoutAddress: payoutAddressValidation,
+  developerFeeBPS: z
+    .string()
+    .transform((val) => Number(val))
+    .refine((val) => val >= 0 && val <= 100, {
+      message: "Developer fee must be between 0 and 100",
+    })
+    .optional(),
path: apps/dashboard/src/components/settings/ApiKeys/validations.ts
user: login: graphite-app[bot]
body: The validation for `developerFeeBPS` currently transforms string inputs to numbers but doesn't properly handle empty strings, which would become `NaN` and fail validation. Consider either:

1. Adding a pre-transform check:
```ts
.transform((val) => val === "" ? undefined : Number(val))
```

2. Or using a more robust approach with `preprocess`:
```ts
z.preprocess(
  (val) => val === "" ? undefined : Number(val),
  z.number().min(0).max(100).optional()
)
```

This would ensure empty inputs are properly handled as optional values rather than failing validation.
```suggestion
  developerFeeBPS: z
    .preprocess(
      (val) => val === "" ? undefined : Number(val),
      z.number().min(0).max(100).optional()
    ),
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=thirdweb-dev&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-21T21:17:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-20T08:25:46Z
body: [vc]: #5KbDjmOQqCLdzof2beZveUlj8JynuM+ML5QQyGmK+wA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJsb2dpbiIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2xvZ2luIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpcmR3ZWIvbG9naW4vSkNhMzZqV0ZpRFdVSlJrVXBncjl3SlltZ2JrUiIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiU0tJUFBFRCJ9LHsibmFtZSI6ImRvY3MtdjIiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9wb3J0YWwiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlyZHdlYi9kb2NzLXYyL3RYRnNwUkdybnd3THhGc05nUHkzOHZWRmV5NUsiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJ0aGlyZHdlYl9wbGF5Z3JvdW5kIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvcGxheWdyb3VuZC13ZWIiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlyZHdlYi90aGlyZHdlYl9wbGF5Z3JvdW5kL3FrMWh0WXdXbUhlOXU5NVdYMU5IVGt5RWNNanUiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJ0aGlyZHdlYi13d3ciLCJyb290RGlyZWN0b3J5IjoiYXBwcy9kYXNoYm9hcmQiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpcmR3ZWIvdGhpcmR3ZWItd3d3LzVpczN6OG11WUZKZnpaaVJaeW1Cc2Z6OThhdUYiLCJwcmV2aWV3VXJsIjoidGhpcmR3ZWItd3d3LWdpdC1kYXNoYm9hcmRhZGR3ZWJob29rdmVyc2lvbnN1cHBvcnR0b3VuaXYtYTU4ODlhLnRoaXJkd2ViLXByZXZpZXcuY29tIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InRoaXJkd2ViLXd3dy1naXQtZGFzaGJvYXJkYWRkd2ViaG9va3ZlcnNpb25zdXBwb3J0dG91bml2LWE1ODg5YS50aGlyZHdlYi1wcmV2aWV3LmNvbSJ9fSx7Im5hbWUiOiJ3YWxsZXQtdWkiLCJyb290RGlyZWN0b3J5IjoiYXBwcy93YWxsZXQtdWkiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90aGlyZHdlYi93YWxsZXQtdWkvR0g0Z29DaDRwRVNKZVlMaHI0a1JkVlVDYWMyaCIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiU0tJUFBFRCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **thirdweb-www** | ✅ Ready ([Inspect](https://vercel.com/thirdweb/thirdweb-www/5is3z8muYFJfzZiRZymBsfz98auF)) | [Visit Preview](https://thirdweb-www-git-dashboardaddwebhookversionsupporttouniv-a5889a.thirdweb-preview.com) | 💬 [**Add feedback**](https://vercel.live/open-feedback/thirdweb-www-git-dashboardaddwebhookversionsupporttouniv-a5889a.thirdweb-preview.com?via=pr-comment-feedback-link) | Apr 22, 2025 0:45am |

<details><summary>4 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **docs-v2** | ⬜️ Skipped ([Inspect](https://vercel.com/thirdweb/docs-v2/tXFspRGrnwwLxFsNgPy38vVFey5K)) |  |  | Apr 22, 2025 0:45am |
| **login** | ⬜️ Skipped ([Inspect](https://vercel.com/thirdweb/login/JCa36jWFiDWUJRkUpgr9wJYmgbkR)) |  |  | Apr 22, 2025 0:45am |
| **thirdweb_playground** | ⬜️ Skipped ([Inspect](https://vercel.com/thirdweb/thirdweb_playground/qk1htYwWmHe9u95WX1NHTkyEcMju)) |  |  | Apr 22, 2025 0:45am |
| **wallet-ui** | ⬜️ Skipped ([Inspect](https://vercel.com/thirdweb/wallet-ui/GH4goCh4pESJeYLhr4kRdVUCac2h)) |  |  | Apr 22, 2025 0:45am |
</details>






author_association: NONE
user: login: changeset-bot[bot]
created_at: 2025-04-20T08:25:49Z
body: ###  ⚠️  No Changeset found

Latest commit: e1165173f2cb8898137c9c522211d72bc94710a7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/thirdweb-dev/js/new/_Dashboard_Add_webhook_version_support_to_Universal_Bridge?filename=.changeset/gentle-masks-judge.md&value=---%0A%22thirdweb-dashboard%22%3A%20patch%0A---%0A%0A%5BDashboard%5D%20Add%20webhook%20version%20support%20to%20Universal%20Bridge%0A)


author_association: NONE
user: login: joaquim-verges
created_at: 2025-04-20T08:25:59Z
body: * **#6779** <a href="https://app.graphite.dev/github/pr/thirdweb-dev/js/6779?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/thirdweb-dev/js/6779?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`
<hr />

## How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:
- <kbd>merge-queue</kbd> - adds this PR to the back of the merge queue
- <kbd>hotfix</kbd> - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using [this link](https://app.graphite.dev/invite/github/thirdweb-dev?ref=merge-queue-instructions-in-stack-comment&prId=6779).

<h2></h2>
<sub>

An organization admin has enabled the [Graphite Merge Queue](https://graphite.dev/docs/graphite-merge-queue) in this repository.

</sub>

<sub>

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

</sub>

<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-04-20T08:28:01Z
body: ## [Codecov](https://app.codecov.io/gh/thirdweb-dev/js/pull/6779?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 55.18%. Comparing base [(`d7dc83f`)](https://app.codecov.io/gh/thirdweb-dev/js/commit/d7dc83f9e65616fe2f61bb2a680429b163cd0307?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev) to head [(`e116517`)](https://app.codecov.io/gh/thirdweb-dev/js/commit/e1165173f2cb8898137c9c522211d72bc94710a7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6779      +/-   ##
==========================================
- Coverage   55.18%   55.18%   -0.01%     
==========================================
  Files         896      896              
  Lines       56934    56940       +6     
  Branches     3944     3943       -1     
==========================================
  Hits        31420    31420              
- Misses      25418    25424       +6     
  Partials       96       96              
```

| [Flag](https://app.codecov.io/gh/thirdweb-dev/js/pull/6779/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev) | Coverage Δ | |
|---|---|---|
| [packages](https://app.codecov.io/gh/thirdweb-dev/js/pull/6779/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev) | `55.18% <ø> (-0.01%)` | :arrow_down: |

[see 1 file with indirect coverage changes](https://app.codecov.io/gh/thirdweb-dev/js/pull/6779/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=thirdweb-dev)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-20T08:30:12Z
body: ## size-limit report 📦 
| Path                                    | Size           | Loading time (3g) | Running time (snapdragon) | Total time |
| --------------------------------------- | -------------- | ----------------- | ------------------------- | ---------- |
| thirdweb (esm)                          | 50.73 KB (0%)  | 1.1 s (0%)        | 235 ms (+47.97% 🔺)       | 1.3 s      |
| thirdweb (cjs)                          | 138.79 KB (0%) | 2.8 s (0%)        | 471 ms (+15.19% 🔺)       | 3.3 s      |
| thirdweb (minimal + tree-shaking)       | 5.6 KB (0%)    | 113 ms (0%)       | 47 ms (+222.84% 🔺)       | 159 ms     |
| thirdweb/chains (tree-shaking)          | 514 B (0%)     | 11 ms (0%)        | 37 ms (+789.84% 🔺)       | 47 ms      |
| thirdweb/react (minimal + tree-shaking) | 19.41 KB (0%)  | 389 ms (0%)       | 101 ms (+283.32% 🔺)      | 489 ms     |
author_association: CONTRIBUTOR