diff --git a/plugins/dub/credentials.ts b/plugins/dub/credentials.ts
new file mode 100644
index 00000000..bc9caba5
--- /dev/null
+++ b/plugins/dub/credentials.ts
@@ -0,0 +1,3 @@
+export type DubCredentials = {
+ DUB_API_KEY?: string;
+};
diff --git a/plugins/dub/icon.tsx b/plugins/dub/icon.tsx
new file mode 100644
index 00000000..a586eb86
--- /dev/null
+++ b/plugins/dub/icon.tsx
@@ -0,0 +1,14 @@
+export function DubIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/plugins/dub/index.ts b/plugins/dub/index.ts
new file mode 100644
index 00000000..ea8d1461
--- /dev/null
+++ b/plugins/dub/index.ts
@@ -0,0 +1,313 @@
+import type { IntegrationPlugin } from "../registry";
+import { registerIntegration } from "../registry";
+import { DubIcon } from "./icon";
+
+const dubPlugin: IntegrationPlugin = {
+ type: "dub",
+ label: "Dub",
+ description: "Create and manage short links",
+
+ icon: DubIcon,
+
+ formFields: [
+ {
+ id: "apiKey",
+ label: "API Key",
+ type: "password",
+ placeholder: "dub_xxx",
+ configKey: "apiKey",
+ envVar: "DUB_API_KEY",
+ helpText: "Get your API key from ",
+ helpLink: {
+ text: "Dub Dashboard",
+ url: "https://app.dub.co/settings/tokens",
+ },
+ },
+ ],
+
+ testConfig: {
+ getTestFunction: async () => {
+ const { testDub } = await import("./test");
+ return testDub;
+ },
+ },
+
+ actions: [
+ {
+ slug: "create-link",
+ label: "Create Link",
+ description: "Create a new short link",
+ category: "Dub",
+ stepFunction: "createLinkStep",
+ stepImportPath: "create-link",
+ outputFields: [
+ { field: "id", description: "Unique link ID" },
+ { field: "shortLink", description: "The full short URL" },
+ { field: "qrCode", description: "QR code URL for the link" },
+ { field: "domain", description: "Short link domain" },
+ { field: "key", description: "Short link slug" },
+ { field: "url", description: "Destination URL" },
+ ],
+ configFields: [
+ {
+ key: "url",
+ label: "Destination URL",
+ type: "template-input",
+ placeholder: "https://example.com/page",
+ example: "https://example.com/landing-page",
+ required: true,
+ },
+ {
+ key: "key",
+ label: "Custom Slug",
+ type: "template-input",
+ placeholder: "my-link",
+ example: "summer-sale",
+ },
+ {
+ key: "domain",
+ label: "Domain",
+ type: "template-input",
+ placeholder: "dub.sh",
+ example: "dub.sh",
+ },
+ {
+ label: "Link IDs",
+ type: "group",
+ fields: [
+ {
+ key: "externalId",
+ label: "External ID",
+ type: "template-input",
+ placeholder: "my-external-id",
+ },
+ {
+ key: "tenantId",
+ label: "Tenant ID",
+ type: "template-input",
+ placeholder: "tenant-123",
+ },
+ {
+ key: "programId",
+ label: "Program ID",
+ type: "template-input",
+ placeholder: "program-123",
+ },
+ {
+ key: "partnerId",
+ label: "Partner ID",
+ type: "template-input",
+ placeholder: "partner-123",
+ },
+ ],
+ },
+ {
+ label: "Link Preview",
+ type: "group",
+ fields: [
+ {
+ key: "title",
+ label: "Title",
+ type: "template-input",
+ placeholder: "Custom preview title",
+ },
+ {
+ key: "description",
+ label: "Description",
+ type: "template-input",
+ placeholder: "Custom preview description",
+ },
+ {
+ key: "image",
+ label: "Image URL",
+ type: "template-input",
+ placeholder: "https://example.com/image.png",
+ },
+ {
+ key: "video",
+ label: "Video URL",
+ type: "template-input",
+ placeholder: "https://example.com/video.mp4",
+ },
+ ],
+ },
+ {
+ label: "UTM Parameters",
+ type: "group",
+ fields: [
+ {
+ key: "utm_source",
+ label: "Source",
+ type: "template-input",
+ placeholder: "newsletter",
+ },
+ {
+ key: "utm_medium",
+ label: "Medium",
+ type: "template-input",
+ placeholder: "email",
+ },
+ {
+ key: "utm_campaign",
+ label: "Campaign",
+ type: "template-input",
+ placeholder: "summer-sale",
+ },
+ {
+ key: "utm_term",
+ label: "Term",
+ type: "template-input",
+ placeholder: "running+shoes",
+ },
+ {
+ key: "utm_content",
+ label: "Content",
+ type: "template-input",
+ placeholder: "logolink",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ slug: "upsert-link",
+ label: "Upsert Link",
+ description: "Create or update a link by URL or external ID",
+ category: "Dub",
+ stepFunction: "upsertLinkStep",
+ stepImportPath: "upsert-link",
+ outputFields: [
+ { field: "id", description: "Unique link ID" },
+ { field: "shortLink", description: "The full short URL" },
+ { field: "qrCode", description: "QR code URL for the link" },
+ { field: "domain", description: "Short link domain" },
+ { field: "key", description: "Short link slug" },
+ { field: "url", description: "Destination URL" },
+ ],
+ configFields: [
+ {
+ key: "url",
+ label: "Destination URL",
+ type: "template-input",
+ placeholder: "https://example.com/page",
+ example: "https://example.com/landing-page",
+ required: true,
+ },
+ {
+ key: "key",
+ label: "Custom Slug",
+ type: "template-input",
+ placeholder: "my-link",
+ example: "summer-sale",
+ },
+ {
+ key: "domain",
+ label: "Domain",
+ type: "template-input",
+ placeholder: "dub.sh",
+ example: "dub.sh",
+ },
+ {
+ label: "Link IDs",
+ type: "group",
+ fields: [
+ {
+ key: "externalId",
+ label: "External ID",
+ type: "template-input",
+ placeholder: "my-external-id",
+ },
+ {
+ key: "tenantId",
+ label: "Tenant ID",
+ type: "template-input",
+ placeholder: "tenant-123",
+ },
+ {
+ key: "programId",
+ label: "Program ID",
+ type: "template-input",
+ placeholder: "program-123",
+ },
+ {
+ key: "partnerId",
+ label: "Partner ID",
+ type: "template-input",
+ placeholder: "partner-123",
+ },
+ ],
+ },
+ {
+ label: "Link Preview",
+ type: "group",
+ fields: [
+ {
+ key: "title",
+ label: "Title",
+ type: "template-input",
+ placeholder: "Custom preview title",
+ },
+ {
+ key: "description",
+ label: "Description",
+ type: "template-input",
+ placeholder: "Custom preview description",
+ },
+ {
+ key: "image",
+ label: "Image URL",
+ type: "template-input",
+ placeholder: "https://example.com/image.png",
+ },
+ {
+ key: "video",
+ label: "Video URL",
+ type: "template-input",
+ placeholder: "https://example.com/video.mp4",
+ },
+ ],
+ },
+ {
+ label: "UTM Parameters",
+ type: "group",
+ fields: [
+ {
+ key: "utm_source",
+ label: "Source",
+ type: "template-input",
+ placeholder: "newsletter",
+ },
+ {
+ key: "utm_medium",
+ label: "Medium",
+ type: "template-input",
+ placeholder: "email",
+ },
+ {
+ key: "utm_campaign",
+ label: "Campaign",
+ type: "template-input",
+ placeholder: "summer-sale",
+ },
+ {
+ key: "utm_term",
+ label: "Term",
+ type: "template-input",
+ placeholder: "running+shoes",
+ },
+ {
+ key: "utm_content",
+ label: "Content",
+ type: "template-input",
+ placeholder: "logolink",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+registerIntegration(dubPlugin);
+
+export default dubPlugin;
diff --git a/plugins/dub/steps/create-link.ts b/plugins/dub/steps/create-link.ts
new file mode 100644
index 00000000..b4a66ec2
--- /dev/null
+++ b/plugins/dub/steps/create-link.ts
@@ -0,0 +1,151 @@
+import "server-only";
+
+import { fetchCredentials } from "@/lib/credential-fetcher";
+import { type StepInput, withStepLogging } from "@/lib/steps/step-handler";
+import { getErrorMessage } from "@/lib/utils";
+import type { DubCredentials } from "../credentials";
+
+const DUB_API_URL = "https://api.dub.co";
+
+type DubLinkResponse = {
+ id: string;
+ domain: string;
+ key: string;
+ url: string;
+ shortLink: string;
+ qrCode: string;
+};
+
+type CreateLinkResult =
+ | {
+ success: true;
+ id: string;
+ shortLink: string;
+ qrCode: string;
+ domain: string;
+ key: string;
+ url: string;
+ }
+ | { success: false; error: string };
+
+export type CreateLinkCoreInput = {
+ url: string;
+ key?: string;
+ domain?: string;
+ externalId?: string;
+ tenantId?: string;
+ programId?: string;
+ partnerId?: string;
+ title?: string;
+ description?: string;
+ image?: string;
+ video?: string;
+ utm_source?: string;
+ utm_medium?: string;
+ utm_campaign?: string;
+ utm_term?: string;
+ utm_content?: string;
+};
+
+export type CreateLinkInput = StepInput &
+ CreateLinkCoreInput & {
+ integrationId?: string;
+ };
+
+async function stepHandler(
+ input: CreateLinkCoreInput,
+ credentials: DubCredentials
+): Promise {
+ const apiKey = credentials.DUB_API_KEY;
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ "DUB_API_KEY is not configured. Please add it in Project Integrations.",
+ };
+ }
+
+ if (!input.url) {
+ return {
+ success: false,
+ error: "Destination URL is required",
+ };
+ }
+
+ try {
+ const body: Record = {
+ url: input.url,
+ };
+
+ if (input.key) body.key = input.key;
+ if (input.domain) body.domain = input.domain;
+ if (input.externalId) body.externalId = input.externalId;
+ if (input.tenantId) body.tenantId = input.tenantId;
+ if (input.programId) body.programId = input.programId;
+ if (input.partnerId) body.partnerId = input.partnerId;
+ if (input.title) body.title = input.title;
+ if (input.description) body.description = input.description;
+ if (input.image) body.image = input.image;
+ if (input.video) body.video = input.video;
+ if (input.utm_source) body.utm_source = input.utm_source;
+ if (input.utm_medium) body.utm_medium = input.utm_medium;
+ if (input.utm_campaign) body.utm_campaign = input.utm_campaign;
+ if (input.utm_term) body.utm_term = input.utm_term;
+ if (input.utm_content) body.utm_content = input.utm_content;
+
+ const response = await fetch(`${DUB_API_URL}/links`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ error?: { message?: string };
+ message?: string;
+ };
+ const errorMessage =
+ errorData.error?.message || errorData.message || `HTTP ${response.status}`;
+ return {
+ success: false,
+ error: errorMessage,
+ };
+ }
+
+ const link = (await response.json()) as DubLinkResponse;
+
+ return {
+ success: true,
+ id: link.id,
+ shortLink: link.shortLink,
+ qrCode: link.qrCode,
+ domain: link.domain,
+ key: link.key,
+ url: link.url,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Failed to create link: ${getErrorMessage(error)}`,
+ };
+ }
+}
+
+export async function createLinkStep(
+ input: CreateLinkInput
+): Promise {
+ "use step";
+
+ const credentials = input.integrationId
+ ? await fetchCredentials(input.integrationId)
+ : {};
+
+ return withStepLogging(input, () => stepHandler(input, credentials));
+}
+createLinkStep.maxRetries = 0;
+
+export const _integrationType = "dub";
diff --git a/plugins/dub/steps/upsert-link.ts b/plugins/dub/steps/upsert-link.ts
new file mode 100644
index 00000000..1d34d903
--- /dev/null
+++ b/plugins/dub/steps/upsert-link.ts
@@ -0,0 +1,151 @@
+import "server-only";
+
+import { fetchCredentials } from "@/lib/credential-fetcher";
+import { type StepInput, withStepLogging } from "@/lib/steps/step-handler";
+import { getErrorMessage } from "@/lib/utils";
+import type { DubCredentials } from "../credentials";
+
+const DUB_API_URL = "https://api.dub.co";
+
+type DubLinkResponse = {
+ id: string;
+ domain: string;
+ key: string;
+ url: string;
+ shortLink: string;
+ qrCode: string;
+};
+
+type UpsertLinkResult =
+ | {
+ success: true;
+ id: string;
+ shortLink: string;
+ qrCode: string;
+ domain: string;
+ key: string;
+ url: string;
+ }
+ | { success: false; error: string };
+
+export type UpsertLinkCoreInput = {
+ url: string;
+ key?: string;
+ domain?: string;
+ externalId?: string;
+ tenantId?: string;
+ programId?: string;
+ partnerId?: string;
+ title?: string;
+ description?: string;
+ image?: string;
+ video?: string;
+ utm_source?: string;
+ utm_medium?: string;
+ utm_campaign?: string;
+ utm_term?: string;
+ utm_content?: string;
+};
+
+export type UpsertLinkInput = StepInput &
+ UpsertLinkCoreInput & {
+ integrationId?: string;
+ };
+
+async function stepHandler(
+ input: UpsertLinkCoreInput,
+ credentials: DubCredentials
+): Promise {
+ const apiKey = credentials.DUB_API_KEY;
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error:
+ "DUB_API_KEY is not configured. Please add it in Project Integrations.",
+ };
+ }
+
+ if (!input.url) {
+ return {
+ success: false,
+ error: "Destination URL is required",
+ };
+ }
+
+ try {
+ const body: Record = {
+ url: input.url,
+ };
+
+ if (input.key) body.key = input.key;
+ if (input.domain) body.domain = input.domain;
+ if (input.externalId) body.externalId = input.externalId;
+ if (input.tenantId) body.tenantId = input.tenantId;
+ if (input.programId) body.programId = input.programId;
+ if (input.partnerId) body.partnerId = input.partnerId;
+ if (input.title) body.title = input.title;
+ if (input.description) body.description = input.description;
+ if (input.image) body.image = input.image;
+ if (input.video) body.video = input.video;
+ if (input.utm_source) body.utm_source = input.utm_source;
+ if (input.utm_medium) body.utm_medium = input.utm_medium;
+ if (input.utm_campaign) body.utm_campaign = input.utm_campaign;
+ if (input.utm_term) body.utm_term = input.utm_term;
+ if (input.utm_content) body.utm_content = input.utm_content;
+
+ const response = await fetch(`${DUB_API_URL}/links/upsert`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as {
+ error?: { message?: string };
+ message?: string;
+ };
+ const errorMessage =
+ errorData.error?.message || errorData.message || `HTTP ${response.status}`;
+ return {
+ success: false,
+ error: errorMessage,
+ };
+ }
+
+ const link = (await response.json()) as DubLinkResponse;
+
+ return {
+ success: true,
+ id: link.id,
+ shortLink: link.shortLink,
+ qrCode: link.qrCode,
+ domain: link.domain,
+ key: link.key,
+ url: link.url,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Failed to upsert link: ${getErrorMessage(error)}`,
+ };
+ }
+}
+
+export async function upsertLinkStep(
+ input: UpsertLinkInput
+): Promise {
+ "use step";
+
+ const credentials = input.integrationId
+ ? await fetchCredentials(input.integrationId)
+ : {};
+
+ return withStepLogging(input, () => stepHandler(input, credentials));
+}
+upsertLinkStep.maxRetries = 0;
+
+export const _integrationType = "dub";
diff --git a/plugins/dub/test.ts b/plugins/dub/test.ts
new file mode 100644
index 00000000..da9eccec
--- /dev/null
+++ b/plugins/dub/test.ts
@@ -0,0 +1,42 @@
+const DUB_API_URL = "https://api.dub.co";
+
+export async function testDub(credentials: Record) {
+ try {
+ const apiKey = credentials.DUB_API_KEY;
+
+ if (!apiKey) {
+ return {
+ success: false,
+ error: "DUB_API_KEY is required",
+ };
+ }
+
+ // Use the links endpoint to validate the API key
+ const response = await fetch(`${DUB_API_URL}/links?page=1&pageSize=1`, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return {
+ success: false,
+ error: "Invalid API key. Please check your Dub API key.",
+ };
+ }
+ return {
+ success: false,
+ error: `API validation failed: HTTP ${response.status}`,
+ };
+ }
+
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/plugins/index.ts b/plugins/index.ts
index 495c6e33..b0716266 100644
--- a/plugins/index.ts
+++ b/plugins/index.ts
@@ -16,6 +16,7 @@
import "./ai-gateway";
import "./blob";
+import "./dub";
import "./clerk";
import "./fal";
import "./firecrawl";