diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx index acbc49b68e..3bd6fb9486 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx @@ -85,7 +85,7 @@ export const KeyCreatedSuccessDialog = ({ description: "Keyspace ID is required to view key details.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); return; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx index d7a161383d..6b1f9e9efe 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx @@ -34,7 +34,7 @@ export const useCreateKey = ( description: err.message || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx index f01f3a101e..887f13de46 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx @@ -118,7 +118,7 @@ export const CreateKeyDialog = ({ description: "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); return; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-credits/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-credits/index.tsx index 4f5287ed61..da9d7254bc 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-credits/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-credits/index.tsx @@ -74,7 +74,7 @@ export const EditCredits = ({ keyDetails, isOpen, onClose }: EditCreditsProps) = description: "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-delete-key.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-delete-key.ts index 3f08071a69..cec98a62f9 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-delete-key.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-delete-key.ts @@ -59,7 +59,7 @@ export const useDeleteKey = (onSuccess?: () => void) => { description: errorMessage || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-credits.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-credits.ts index acee97dcba..9403c07d6b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-credits.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-credits.ts @@ -33,7 +33,7 @@ export const useEditCredits = (onSuccess?: () => void) => { description: err.message || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-key.tsx index 672601bf49..1c0d89e63f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-key.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-key.tsx @@ -45,7 +45,7 @@ export const useEditKeyName = (onSuccess: () => void) => { description: errorMessage || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts index a17fec8c14..c68f31858b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts @@ -51,7 +51,7 @@ export const useEditRatelimits = (onSuccess?: () => void) => { description: err.message || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx index 422fc135b7..4abf0c45d2 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-update-key-status.tsx @@ -19,7 +19,7 @@ const handleKeyUpdateError = (err: TRPCClientErrorLike) => { description: errorMessage || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), }, }); } diff --git a/apps/dashboard/app/(app)/settings/billing/client.tsx b/apps/dashboard/app/(app)/settings/billing/client.tsx index f4dd2f4d68..961f1aaa83 100644 --- a/apps/dashboard/app/(app)/settings/billing/client.tsx +++ b/apps/dashboard/app/(app)/settings/billing/client.tsx @@ -23,7 +23,6 @@ type Props = { subscription?: { id: string; status: Stripe.Subscription.Status; - trialUntil?: number; cancelAt?: number; }; currentProductId?: string; @@ -88,14 +87,10 @@ const Mutations = () => { export const Client: React.FC = (props) => { const mutations = Mutations(); - const allowUpdate = - props.subscription && ["active", "trialing"].includes(props.subscription.status); + const allowUpdate = props.subscription && props.subscription.status === "active"; const allowCancel = - props.subscription && - ["active", "trialing"].includes(props.subscription.status) && - !props.subscription.cancelAt; - const isFreeTier = - !props.subscription || !["active", "trialing"].includes(props.subscription.status); + props.subscription && props.subscription.status === "active" && !props.subscription.cancelAt; + const isFreeTier = !props.subscription || props.subscription.status !== "active"; const selectedProductIndex = allowUpdate ? props.products.findIndex((p) => p.id === props.currentProductId) : -1; @@ -107,12 +102,7 @@ export const Client: React.FC = (props) => { activePage={{ href: "billing", text: "Billing" }} /> - {props.subscription ? ( - - ) : null} + {props.subscription ? : null} {isFreeTier ? : null} @@ -192,14 +182,9 @@ export const Client: React.FC = (props) => { productId: p.id, }) } - fineprint={ - props.hasPreviousSubscriptions - ? "Do you need another trial? Contact support.unkey.dev" - : "After 14 days, the trial converts to a paid subscription." - } trigger={(onClick) => ( )} /> @@ -213,7 +198,7 @@ export const Client: React.FC = (props) => { @@ -318,35 +303,10 @@ const CancelAlert: React.FC<{ cancelAt?: number }> = (props) => { }; const SusbcriptionStatus: React.FC<{ status: Stripe.Subscription.Status; - trialUntil?: number; }> = (props) => { switch (props.status) { case "active": return null; - case "trialing": - if (!props.trialUntil) { - return null; - } - return ( - - Your trial ends in{" "} - - {ms(props.trialUntil - Date.now(), { long: true })} - {" "} - on{" "} - - {new Date(props.trialUntil).toLocaleDateString()} - - . - - } - border="both" - className="border-info-7 bg-info-3" - /> - ); case "incomplete": case "incomplete_expired": diff --git a/apps/dashboard/app/(app)/settings/billing/page.tsx b/apps/dashboard/app/(app)/settings/billing/page.tsx index 8ec5bca85a..f946ce3e08 100644 --- a/apps/dashboard/app/(app)/settings/billing/page.tsx +++ b/apps/dashboard/app/(app)/settings/billing/page.tsx @@ -174,7 +174,6 @@ export default async function BillingPage() { ? { id: subscription.id, status: subscription.status, - trialUntil: subscription.trial_end ? subscription.trial_end * 1000 : undefined, cancelAt: subscription.cancel_at ? subscription.cancel_at * 1000 : undefined, } : undefined diff --git a/apps/dashboard/app/api/webhooks/stripe/route.ts b/apps/dashboard/app/api/webhooks/stripe/route.ts index ada00c4e06..b4a77cc088 100644 --- a/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -86,7 +86,7 @@ export const POST = async (req: Request): Promise => { try { const sub = event.data.object as Stripe.Subscription; - if (!sub.trial_end || !sub.items?.data?.[0]?.price?.id || !sub.customer) { + if (!sub.items?.data?.[0]?.price?.id || !sub.customer) { return new Response("OK"); } @@ -151,14 +151,14 @@ async function alertSlack( type: "section", text: { type: "mrkdwn", - text: `:bugeyes: New customer ${name} signed up for a two week trial`, + text: `:bugeyes: New customer ${name} signed up`, }, }, { type: "section", text: { type: "mrkdwn", - text: `A new trial for the ${product} tier has started at a price of ${price} by ${email} :moneybag: `, + text: `A new subscription for the ${product} tier has started at a price of ${price} by ${email} :moneybag: `, }, }, ], diff --git a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts index a84d9adc68..c324d6137f 100644 --- a/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts +++ b/apps/dashboard/lib/trpc/routers/stripe/createSubscription.ts @@ -64,14 +64,6 @@ export const createSubscription = t.procedure }); } - const hasPreviousSubscriptions = await stripe.subscriptions - .list({ - customer: customer.id, - status: "all", - limit: 1, - }) - .then((res) => res.data.length > 0); - const sub = await stripe.subscriptions.create({ customer: customer.id, items: [ @@ -82,7 +74,6 @@ export const createSubscription = t.procedure billing_cycle_anchor_config: { day_of_month: 1, }, - trial_period_days: hasPreviousSubscriptions ? undefined : 14, proration_behavior: "always_invoice", }); diff --git a/apps/engineering/content/docs/infrastructure/stripe/subscriptions.mdx b/apps/engineering/content/docs/infrastructure/stripe/subscriptions.mdx index ce493109a8..709f2f99ef 100644 --- a/apps/engineering/content/docs/infrastructure/stripe/subscriptions.mdx +++ b/apps/engineering/content/docs/infrastructure/stripe/subscriptions.mdx @@ -6,22 +6,22 @@ description: How Unkey uses Stripe subscriptions Our Stripe billing integration is designed with several important objectives: 1. **Calendar Month Alignment** + - All billing cycles align with calendar months (1st to end of month) - Provides predictable billing dates for customers - Simplifies usage calculations and reporting 2. **Free Tier Efficiency** + - Free tier users don't require Stripe customers or subscriptions - Only create Stripe resources when users actively choose to upgrade - Keeps Stripe dashboard clean and focused on paying customers 3. **Frictionless Upgrades** - Seamless transition from free to paid - - Transparent trial process with payment method collection upfront + - Transparent upgrade process with payment method collection upfront - Clear visibility into usage and quotas - - ## System Overview Unkey's Stripe billing integration manages user subscriptions through a tiered pricing model, with support for legacy usage-based pricing. The system handles payment methods, trials, subscription management, and customer portals. @@ -39,7 +39,7 @@ The high-level user flow is as follows: ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ │ │ │ │ │ -│ Active Plan│◄────│ Start Trial│◄────│ Add Payment│◄────│ User │ +│ Active Plan│◄────│ Upgrade │◄────│ Add Payment│◄────│ User │ │ │ │ │ │ Method │ │ Action │ └────────────┘ └────────────┘ └────────────┘ └────────────┘ ``` @@ -47,17 +47,20 @@ The high-level user flow is as follows: ## Key Components 1. **Workspace Billing Configuration** + - `stripeCustomerId`: Links workspace to Stripe customer - `stripeSubscriptionId`: Links workspace to Stripe subscription - `tier`: Current plan tier (free, pro, etc.) - `quota`: Usage limits (primarily `requestsPerMonth`) 2. **Tiered Products** + - Set of products with increasing request quotas - Clear upgrade/downgrade paths between plans - Price points displayed with monthly pricing 3. **Stripe Integration** + - Payment setup with checkout sessions - Subscription management - Customer portal access @@ -75,7 +78,8 @@ The high-level user flow is as follows: To enable Stripe billing functionality in Unkey, you need to configure the following environment variables: 1. **`STRIPE_SECRET_KEY`** - - Your Stripe API secret key (begins with "sk_") + + - Your Stripe API secret key (begins with "sk\_") - Used for authenticating server-side API calls to Stripe - Example: `STRIPE_SECRET_KEY=sk_test_51MpJhKLoBBjyJTsUAbcXYZ...` @@ -89,6 +93,7 @@ The system is designed to gracefully handle missing Stripe configuration, displa ### Workspace Database Schema The workspace schema includes: + ```typescript workspaces { id: string @@ -106,13 +111,6 @@ workspaces { } ``` -### Trial Management - -- 14-day trial period -- Payment method collected upfront -- Trial converts to paid subscription automatically -- Trial end date clearly displayed to customers - ## User Flows ### Adding a Payment Method @@ -130,7 +128,7 @@ Before starting a subscription, users must add a payment method: Once a payment method is available: -1. User selects a plan and clicks "Start 14 day trial" (or "Upgrade" if they've had a trial before) +1. User selects a plan and clicks "Upgrade" 2. System checks if user has a customer ID: - If not, creates a checkout session for payment method collection first - If yes, creates a subscription with the selected product @@ -188,6 +186,7 @@ The system handles users who were on legacy usage-based pricing: ### Product Metadata Products in Stripe contain crucial metadata: + - `quota_requests_per_month`: Maximum API requests allowed - `quota_logs_retention_days`: How long logs are retained - `quota_audit_logs_retention_days`: How long audit logs are retained @@ -195,6 +194,7 @@ Products in Stripe contain crucial metadata: ### Usage Calculation Usage is calculated as: + - Combined total of valid key verifications and ratelimits - Reset at the beginning of each calendar month - Displayed as both raw numbers and percentage of quota diff --git a/deployment/data/metald/metald.db b/deployment/data/metald/metald.db new file mode 100644 index 0000000000..8e74adf45c Binary files /dev/null and b/deployment/data/metald/metald.db differ diff --git a/internal/resend/emails/trial_ended.tsx b/internal/resend/emails/trial_ended.tsx deleted file mode 100644 index b871438ad6..0000000000 --- a/internal/resend/emails/trial_ended.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; -import { Button } from "@react-email/button"; -import { Heading } from "@react-email/heading"; -import { Hr } from "@react-email/hr"; -import { Link } from "@react-email/link"; -import { Section } from "@react-email/section"; -import { Text } from "@react-email/text"; -// biome-ignore lint/correctness/noUnusedImports: react-email needs this imported -import React from "react"; -import { Layout } from "../src/components/layout"; -import { Signature } from "../src/components/signature"; - -export type Props = { - username: string; - workspaceName: string; -}; - -export function TrialEnded({ workspaceName, username }: Props) { - return ( - - - Your workspace {workspaceName} has reached the end of its trial. - - Hey {username}, - - We hope you've enjoyed your two-week Unkey Pro trial for your workspace{" "} - {workspaceName}. - - - - Since your trial ended, please add a payment method to keep all features of the Pro plan. - - -
- - It's simple to upgrade and enjoy the benefits of the Unkey Pro plan: - -
    -
  • - {" "} - 1M monthly active keys included{" "} - (free users only get 1k total) -
  • -
  • - {" "} - 150k monthly verifications included{" "} - (free users only get 2.5k per month) -
  • -
  • - {" "} - 2.5M monthly ratelimits included{" "} - (free users only get 100k per month) -
  • -
- Pro workspaces also receive: -
    -
  • - {" "} - Unlimited seats at no additional cost so you can invite your whole team -
  • -
  • 90-day analytics retention
  • -
  • 90-day audit log retention
  • -
  • Priority Support
  • -
-
- -
- -
- -
- - - Need help? Please reach out to{" "} - support@unkey.dev or just reply to this email. - - - -
- ); -} - -TrialEnded.PreviewProps = { - username: "Spongebob Squarepants", - workspaceName: "Krusty crab", -} satisfies Props; - -// biome-ignore lint/style/noDefaultExport: Too scared to modify that one -export default TrialEnded; diff --git a/internal/resend/src/client.tsx b/internal/resend/src/client.tsx index 3edff11280..35eb536dc9 100644 --- a/internal/resend/src/client.tsx +++ b/internal/resend/src/client.tsx @@ -6,7 +6,6 @@ import React from "react"; import { ApiV1Migration } from "../emails/api_v1_migration"; import { PaymentIssue } from "../emails/payment_issue"; import { SecretScanningKeyDetected } from "../emails/secret_scanning_key_detected"; -import { TrialEnded } from "../emails/trial_ended"; import { WelcomeEmail } from "../emails/welcome_email"; export class Resend { public readonly client: Client; @@ -16,30 +15,6 @@ export class Resend { this.client = new Client(opts.apiKey); } - public async sendTrialEnded(req: { - email: string; - name: string; - workspace: string; - }): Promise { - const html = render(); - try { - const result = await this.client.emails.send({ - to: req.email, - from: "James from Unkey ", - replyTo: this.replyTo, - subject: "Your Unkey trial has ended", - html, - }); - - if (!result.error) { - return; - } - throw result.error; - } catch (error) { - console.error("Error occurred sending subscription email ", JSON.stringify(error)); - } - } - public async sendWelcomeEmail(req: { email: string }) { const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000).toISOString();