├── .gitignore ├── engine.png ├── src ├── stripeClient.ts ├── prisma.ts ├── app.ts ├── actions │ ├── user.ts │ └── billing.ts ├── controllers │ ├── common │ │ ├── errorHandler.ts │ │ └── auth.ts │ ├── billing.ts │ ├── clerkWebhooks.ts │ ├── gpt.ts │ └── stripeWebhooks.ts ├── constants.ts ├── types │ └── billing.ts └── server.ts ├── .env.example ├── tsconfig.json ├── prisma └── schema.prisma ├── package.json ├── scripts ├── clerkCreate.ts └── clerkUpdate.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | prisma/dev.db 3 | .env 4 | dist 5 | clerk-oauth-app.json 6 | -------------------------------------------------------------------------------- /engine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/gpt-billing-template/HEAD/engine.png -------------------------------------------------------------------------------- /src/stripeClient.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { STRIPE_SECRET_KEY } from "./constants"; 3 | 4 | export const stripeClient = new Stripe(STRIPE_SECRET_KEY!); 5 | -------------------------------------------------------------------------------- /src/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient({ 4 | log: ["query", "info", "warn", "error"], 5 | }); 6 | 7 | export default prisma; 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | 3 | GPT_URL= 4 | 5 | CLERK_WEBHOOK_SECRET= 6 | CLERK_PUBLISHABLE_KEY= 7 | CLERK_SECRET_KEY= 8 | CLERK_USER_INFO_URL= 9 | 10 | SERVER_URL= 11 | 12 | STRIPE_SECRET_KEY= 13 | STRIPE_PRICE_ID= 14 | STRIPE_WEBHOOK_SECRET= 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "NodeNext", 5 | "resolveJsonModule": true, 6 | "outDir": "./dist", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; 3 | import fastify from "fastify"; 4 | import { LOG_LEVEL } from "./constants"; 5 | 6 | const app = fastify({ 7 | logger: { 8 | level: LOG_LEVEL, 9 | }, 10 | }).withTypeProvider(); 11 | 12 | export default app; 13 | export const logger = app.log; 14 | -------------------------------------------------------------------------------- /src/actions/user.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../prisma"; 2 | 3 | export async function createUser(clerkId: string): Promise { 4 | const existingUser = await prisma.user.findFirst({ 5 | where: { 6 | clerk_id: clerkId, 7 | }, 8 | }); 9 | 10 | if (existingUser) { 11 | console.log(`User ${clerkId} already exists`); 12 | return; 13 | } 14 | 15 | await prisma.user.create({ 16 | data: { 17 | clerk_id: clerkId, 18 | }, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id Int @id @default(autoincrement()) 12 | clerk_id String @unique 13 | stripe_customer_id String? 14 | stripe_subscription_id String? 15 | stripe_checkout_session_id String? 16 | subscribed Boolean @default(false) 17 | created_at DateTime @default(now()) 18 | } 19 | -------------------------------------------------------------------------------- /src/controllers/common/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; 2 | import { logger } from "../../app"; 3 | import axios from "axios"; 4 | 5 | export function errorHandler( 6 | this: FastifyInstance, 7 | error: Error, 8 | _request: FastifyRequest, 9 | reply: FastifyReply 10 | ) { 11 | logger.error(error); 12 | let errorMessage = error.message; 13 | if (axios.isAxiosError(error) && error.response) { 14 | errorMessage = error.response.data.errorMessage; 15 | } 16 | 17 | reply.code(500).send({ 18 | status: 500, 19 | error: errorMessage, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOG_LEVEL = process.env.LOG_LEVEL || "info"; 2 | 3 | export const CLERK_USER_INFO_URL = process.env.CLERK_USER_INFO_URL; 4 | export const CLERK_WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET; 5 | 6 | export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; 7 | export const STRIPE_PRICE_ID = process.env.STRIPE_PRICE_ID; 8 | export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; 9 | 10 | export const GPT_URL = process.env.GPT_URL; 11 | 12 | export const SERVER_URL = process.env.SERVER_URL || "http://localhost:8080"; 13 | 14 | export const TRIAL_DAYS = process.env.TRIAL_DAYS 15 | ? parseInt(process.env.TRIAL_DAYS) 16 | : 7; 17 | -------------------------------------------------------------------------------- /src/types/billing.ts: -------------------------------------------------------------------------------- 1 | import { Static, Type } from "@sinclair/typebox"; 2 | 3 | const BillingResponse = Type.Object({ 4 | subscriptionStatus: Type.String(), 5 | trialDaysRemaining: Type.Optional(Type.Number()), 6 | purchaseSubscriptionLink: Type.Optional(Type.String()), 7 | subscriptionManagementLink: Type.Optional(Type.String()), 8 | }); 9 | 10 | export type BillingConfig = Static; 11 | 12 | export const BillingResponseSchema = { 13 | 200: BillingResponse, 14 | "4xx": Type.Object({ 15 | error: Type.String(), 16 | }), 17 | }; 18 | 19 | const BillingResponseObject = Type.Object(BillingResponseSchema); 20 | 21 | export type BillingResponseType = Static; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "tsx watch ./src/server.ts -p tsconfig.json", 4 | "build": "npx prisma generate && tsc -p tsconfig.json", 5 | "start": "node ./dist/server.js", 6 | "clerk-oauth-create": "tsx ./scripts/clerkCreate.ts", 7 | "clerk-oauth-update": "tsx ./scripts/clerkUpdate.ts" 8 | }, 9 | "dependencies": { 10 | "@clerk/fastify": "^0.6.22", 11 | "@fastify/swagger": "^8.12.0", 12 | "@fastify/swagger-ui": "^1.10.1", 13 | "@fastify/type-provider-typebox": "^3.5.0", 14 | "@prisma/client": "^5.6.0", 15 | "axios": "^1.6.2", 16 | "dotenv": "^16.3.1", 17 | "fastify": "^4.24.3", 18 | "stripe": "^14.4.0", 19 | "svix": "^1.13.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.9.0", 23 | "prisma": "^5.6.0", 24 | "tsx": "^4.1.2", 25 | "typescript": "^5.2.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/controllers/common/auth.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma"; 2 | import axios from "axios"; 3 | import { CLERK_USER_INFO_URL } from "../../constants"; 4 | import { getTrialDaysRemaining } from "../../actions/billing"; 5 | 6 | export async function getUserInfo(token: string) { 7 | if (!CLERK_USER_INFO_URL) { 8 | throw new Error("Missing CLERK_USER_INFO_URL"); 9 | } 10 | const response = await axios.get(CLERK_USER_INFO_URL, { 11 | headers: { 12 | Authorization: `Bearer ${token}`, 13 | "Content-Type": "application/json", 14 | }, 15 | }); 16 | return response.data; 17 | } 18 | 19 | export async function getUserFromToken(token: string) { 20 | const userInfo = await getUserInfo(token); 21 | 22 | return await prisma.user.findUnique({ 23 | where: { 24 | clerk_id: userInfo.user_id, 25 | }, 26 | }); 27 | } 28 | 29 | export async function canMakeApiCall(token: string): Promise { 30 | const user = await getUserFromToken(token); 31 | if (!user) return false; 32 | 33 | return user.subscribed || (await getTrialDaysRemaining(user)) > 0; 34 | } 35 | -------------------------------------------------------------------------------- /src/controllers/billing.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { BillingResponseType, BillingResponseSchema } from "../types/billing"; 3 | import { getUserBillingConfig } from "../actions/billing"; 4 | import { getUserFromToken } from "./common/auth"; 5 | 6 | export default async function billing(server: FastifyInstance) { 7 | server.get<{ 8 | Reply: BillingResponseType; 9 | }>( 10 | "/billing", 11 | { 12 | schema: { 13 | response: BillingResponseSchema, 14 | operationId: "getBilling", 15 | }, 16 | }, 17 | async (request, reply) => { 18 | const token = request.headers.authorization?.split(" ")[1]; 19 | if (!token) { 20 | return reply.code(401).send({ error: "Unauthorized" }); 21 | } 22 | 23 | const user = await getUserFromToken(token); 24 | 25 | if (!user) { 26 | return reply.code(404).send({ error: "User not found" }); 27 | } 28 | 29 | const billingConfig = await getUserBillingConfig(user); 30 | 31 | return reply.code(200).send(billingConfig); 32 | } 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/controllers/clerkWebhooks.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { createUser } from "../actions/user"; 3 | import { Webhook } from "svix"; 4 | import type { WebhookEvent } from "@clerk/clerk-sdk-node"; 5 | import { CLERK_WEBHOOK_SECRET } from "../constants"; 6 | 7 | const webhook = new Webhook(CLERK_WEBHOOK_SECRET!); 8 | 9 | export default async function clerkWebhooks(server: FastifyInstance) { 10 | server.post( 11 | "/webhooks/clerk", 12 | { 13 | schema: { 14 | hide: true, 15 | }, 16 | }, 17 | async (request, reply) => { 18 | const body = request.body as any; 19 | 20 | try { 21 | webhook.verify( 22 | request.body as any, 23 | request.headers as Record 24 | ) as WebhookEvent; 25 | } catch (err) { 26 | throw new Error(`Webhook verification error: ${err}`); 27 | } 28 | 29 | switch (body.type) { 30 | case "user.created": 31 | console.log(`Clerk webhook: user.created ${body.data.id}`); 32 | await createUser(body.data.id); 33 | return reply.code(204).send({}); 34 | } 35 | 36 | reply.code(400).send({}); 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/controllers/gpt.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { canMakeApiCall, getUserFromToken } from "./common/auth"; 3 | import { getUserBillingConfig } from "../actions/billing"; 4 | 5 | export default async function gpt(server: FastifyInstance) { 6 | // TODO: Add validation and types 7 | server.get( 8 | "/hello-world", 9 | { 10 | schema: { 11 | security: [{ OAuth2: [] }], 12 | operationId: "helloWorld", 13 | }, 14 | }, 15 | async (request, reply) => { 16 | const token = request.headers.authorization?.split(" ")[1]; 17 | if (!token) { 18 | return reply.status(401).send({ error: "Unauthorized" }); 19 | } 20 | 21 | const user = await getUserFromToken(token); 22 | if (!user) { 23 | reply.code(404).send({ error: "User not found" }); 24 | return; 25 | } 26 | 27 | if (!(await canMakeApiCall(token))) { 28 | const billingConfig = await getUserBillingConfig(user); 29 | return reply.code(402).send({ 30 | error: `Please subscribe to continue using this API by visiting: ${billingConfig.purchaseSubscriptionLink}`, 31 | }); 32 | } 33 | 34 | reply.code(200).send({ 35 | message: "Hello world", 36 | }); 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /scripts/clerkCreate.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { AxiosRequestConfig } from "axios"; 3 | import axios from "axios"; 4 | import { writeFileSync } from "fs"; 5 | 6 | const clerkApiKey = process.env.CLERK_SECRET_KEY; 7 | const oauthAppName = "gpt-clerk-oauth-app"; 8 | 9 | type ClerkOauthCreateResponse = { 10 | object: string; 11 | id: string; 12 | instance_id: string; 13 | name: string; 14 | client_id: string; 15 | client_secret: string; 16 | public: boolean; 17 | scopes: string; 18 | callback_url: string; 19 | authorize_url: string; 20 | token_fetch_url: string; 21 | user_info_url: string; 22 | created_at: number; 23 | updated_at: number; 24 | }; 25 | 26 | const config: AxiosRequestConfig = { 27 | headers: { 28 | contentType: "application/json", 29 | Authorization: `Bearer ${clerkApiKey}`, 30 | }, 31 | }; 32 | 33 | const clerkAxios = axios.create({ 34 | baseURL: "https://api.clerk.com/v1", 35 | }); 36 | 37 | async function createClerkOauthApp(): Promise { 38 | const url = `/oauth_applications`; 39 | const requestData = { 40 | callback_url: "https://example.com/oauth2/callback", 41 | name: oauthAppName, 42 | }; 43 | const response = await clerkAxios.post( 44 | url, 45 | requestData, 46 | config 47 | ); 48 | writeFileSync("./clerk-oauth-app.json", JSON.stringify(response.data)); 49 | console.log(`Clerk OAuth App created ${response.data.id}`); 50 | } 51 | 52 | createClerkOauthApp(); 53 | -------------------------------------------------------------------------------- /scripts/clerkUpdate.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { AxiosRequestConfig } from "axios"; 3 | import axios from "axios"; 4 | import { readFile, readFileSync, writeFileSync } from "fs"; 5 | 6 | const clerkApiKey = process.env.CLERK_SECRET_KEY; 7 | const oauthAppName = process.env.OAUTH_APP_NAME || "gpt-clerk-oauth-app"; 8 | 9 | type ClerkOauthUpdateResponse = { 10 | object: string; 11 | id: string; 12 | instance_id: string; 13 | name: string; 14 | client_id: string; 15 | public: boolean; 16 | scopes: string; 17 | callback_url: string; 18 | authorize_url: string; 19 | token_fetch_url: string; 20 | user_info_url: string; 21 | created_at: number; 22 | updated_at: number; 23 | }; 24 | 25 | const config: AxiosRequestConfig = { 26 | headers: { 27 | contentType: "application/json", 28 | Authorization: `Bearer ${clerkApiKey}`, 29 | }, 30 | }; 31 | 32 | const clerkAxios = axios.create({ 33 | baseURL: "https://api.clerk.com/v1", 34 | }); 35 | 36 | async function updateClerkOauthApp(callbackUrl: string): Promise { 37 | const OauthData = readFileSync("./clerk-oauth-app.json", "utf8"); 38 | const { id } = JSON.parse(OauthData); 39 | const url = `/oauth_applications/${id}`; 40 | const requestData = { 41 | callback_url: callbackUrl, 42 | }; 43 | const response = await clerkAxios.patch( 44 | url, 45 | requestData, 46 | config 47 | ); 48 | writeFileSync("./clerk-oauth-app.json", JSON.stringify(response.data)); 49 | console.log(`Clerk OAuth Callback URL updated ${response.data.id}`); 50 | } 51 | 52 | async function main() { 53 | const args = process.argv; 54 | const callbackUrl = args[2]; // args[0] is node, args[1] is the script path 55 | 56 | if (callbackUrl) { 57 | await updateClerkOauthApp(callbackUrl); 58 | } else { 59 | console.log("No argument provided"); 60 | } 61 | } 62 | 63 | main(); 64 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import app from "./app"; 2 | 3 | import { clerkPlugin } from "@clerk/fastify"; 4 | import fastifySwagger, { FastifyDynamicSwaggerOptions } from "@fastify/swagger"; 5 | import fastifySwaggerUi from "@fastify/swagger-ui"; 6 | import clerkWebhooks from "./controllers/clerkWebhooks"; 7 | import { errorHandler } from "./controllers/common/errorHandler"; 8 | import gpt from "./controllers/gpt"; 9 | import billing from "./controllers/billing"; 10 | import stripeWebhooks from "./controllers/stripeWebhooks"; 11 | import { SERVER_URL } from "./constants"; 12 | 13 | const port = parseInt(process.env.PORT || "8080"); 14 | const host = "RENDER" in process.env ? "0.0.0.0" : "localhost"; 15 | 16 | const swaggerOptions: FastifyDynamicSwaggerOptions = { 17 | openapi: { 18 | info: { 19 | title: "GPT API Template", 20 | version: "0.1.0", 21 | }, 22 | servers: [ 23 | { 24 | url: SERVER_URL, 25 | }, 26 | ], 27 | components: { 28 | securitySchemes: { 29 | OAuth2: { 30 | type: "oauth2", 31 | flows: { 32 | authorizationCode: { 33 | authorizationUrl: "https://example.com", // HACK: GPT actions don't seem to care about this 34 | tokenUrl: "https://example.com", // HACK: GPT actions don't seem to care about this 35 | scopes: {}, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }; 43 | 44 | const swaggerUiOptions = { 45 | routePrefix: "/docs", 46 | exposeRoute: true, 47 | }; 48 | 49 | app.register(fastifySwagger, swaggerOptions); 50 | app.register(fastifySwaggerUi, swaggerUiOptions); 51 | app.setErrorHandler(errorHandler); 52 | 53 | app.register(gpt); 54 | app.register(clerkWebhooks); 55 | app.register(stripeWebhooks); 56 | app.register(billing); 57 | 58 | app.register(clerkPlugin); 59 | 60 | app.listen({ host: host, port: port }, (err, address) => { 61 | if (err) { 62 | console.error(err); 63 | process.exit(1); 64 | } 65 | console.log(`Server listening at ${address}`); 66 | }); 67 | -------------------------------------------------------------------------------- /src/controllers/stripeWebhooks.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { STRIPE_WEBHOOK_SECRET } from "../constants"; 3 | import Stripe from "stripe"; 4 | import { stripeClient } from "../stripeClient"; 5 | import prisma from "../prisma"; 6 | 7 | export default async function stripeWebhooks(server: FastifyInstance) { 8 | // TODO: Add validation and types 9 | server.post( 10 | "/webhooks/stripe", 11 | { 12 | schema: { 13 | hide: true, 14 | }, 15 | }, 16 | async (request, reply) => { 17 | const body = request.body as any; 18 | 19 | const sig = request.headers["stripe-signature"]; 20 | if (body) { 21 | return reply.code(400).send({ error: "No request body provided" }); 22 | } 23 | if (!sig) { 24 | return reply.code(400).send({ error: "No signature header found" }); 25 | } 26 | if (!STRIPE_WEBHOOK_SECRET) { 27 | return reply.code(400).send({ error: "Endpoint secret not set" }); 28 | } 29 | 30 | let webhookEvent: Stripe.Event; 31 | try { 32 | webhookEvent = stripeClient.webhooks.constructEvent( 33 | body, 34 | sig, 35 | STRIPE_WEBHOOK_SECRET 36 | ); 37 | } catch (err) { 38 | return reply 39 | .code(400) 40 | .send({ error: `Webhook verification error: ${err}` }); 41 | } 42 | 43 | // HACK: there should only be one user per stripe customer ID 44 | switch (webhookEvent.type) { 45 | case "customer.subscription.deleted": { 46 | await prisma.user.updateMany({ 47 | where: { 48 | stripe_customer_id: webhookEvent.data.object.customer as string, 49 | }, 50 | data: { 51 | subscribed: false, 52 | }, 53 | }); 54 | return reply.code(204).send({}); 55 | } 56 | case "customer.subscription.updated": { 57 | await prisma.user.updateMany({ 58 | where: { 59 | stripe_customer_id: webhookEvent.data.object.customer as string, 60 | }, 61 | data: { 62 | subscribed: webhookEvent.data.object.status === "active", 63 | }, 64 | }); 65 | return reply.code(204).send({}); 66 | } 67 | case "checkout.session.completed": { 68 | const checkoutSession = webhookEvent.data 69 | .object as Stripe.Checkout.Session; 70 | await prisma.user.updateMany({ 71 | where: { 72 | stripe_checkout_session_id: checkoutSession.id, 73 | }, 74 | data: { 75 | stripe_subscription_id: checkoutSession.subscription as string, 76 | subscribed: true, 77 | }, 78 | }); 79 | return reply.code(204).send({}); 80 | } 81 | default: { 82 | return reply.code(400).send({ error: "Unhandled event type" }); 83 | } 84 | } 85 | } 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/actions/billing.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import Stripe from "stripe"; 3 | import { GPT_URL, STRIPE_PRICE_ID, TRIAL_DAYS } from "../constants"; 4 | import prisma from "../prisma"; 5 | import { BillingConfig } from "../types/billing"; 6 | import { stripeClient } from "../stripeClient"; 7 | 8 | async function createStripeCustomer(user: User): Promise { 9 | const params: Stripe.CustomerCreateParams = { 10 | metadata: { 11 | user_id: user.id, 12 | }, 13 | }; 14 | 15 | const customer = await stripeClient.customers.create(params, { 16 | idempotencyKey: `${user.id}`, 17 | }); 18 | 19 | await prisma.user.update({ 20 | where: { 21 | id: user.id, 22 | }, 23 | data: { 24 | stripe_customer_id: customer.id, 25 | }, 26 | }); 27 | 28 | return customer.id; 29 | } 30 | 31 | export async function getTrialDaysRemaining(user: User) { 32 | const daysSinceCreation = Math.floor( 33 | (Date.now() - user.created_at.getTime()) / 1000 / 60 / 60 / 24 34 | ); 35 | 36 | return TRIAL_DAYS - daysSinceCreation; 37 | } 38 | 39 | async function getOrCreateCheckoutSession( 40 | user: User, 41 | stripeCustomerId: string 42 | ) { 43 | // if there's an open checkout session already, just return it 44 | if (user.stripe_checkout_session_id) { 45 | const checkoutSession = await stripeClient.checkout.sessions.retrieve( 46 | user.stripe_checkout_session_id 47 | ); 48 | 49 | if (checkoutSession.status === "open") { 50 | return checkoutSession; 51 | } 52 | } 53 | 54 | const params: Stripe.Checkout.SessionCreateParams = { 55 | mode: "subscription", 56 | line_items: [ 57 | { 58 | price: STRIPE_PRICE_ID, 59 | quantity: 1, 60 | }, 61 | ], 62 | customer: stripeCustomerId, 63 | success_url: GPT_URL, 64 | cancel_url: GPT_URL, 65 | allow_promotion_codes: true, 66 | }; 67 | 68 | const checkoutSession = await stripeClient.checkout.sessions.create(params); 69 | 70 | await prisma.user.update({ 71 | where: { 72 | id: user.id, 73 | }, 74 | data: { 75 | stripe_checkout_session_id: checkoutSession.id, 76 | }, 77 | }); 78 | 79 | return checkoutSession; 80 | } 81 | 82 | export async function getUserBillingConfig(user: User): Promise { 83 | if (user.subscribed) { 84 | // paid tier - get subscription management link and return 85 | const billingPortal = await stripeClient.billingPortal.sessions.create({ 86 | customer: user.stripe_customer_id!, // if stripe_subscription_id is set, then stripe_customer_id must be set 87 | return_url: GPT_URL, 88 | }); 89 | 90 | return { 91 | subscriptionStatus: "subscription_active", 92 | subscriptionManagementLink: billingPortal.url, 93 | }; 94 | } else { 95 | // free tier 96 | const trialDaysRemaining = await getTrialDaysRemaining(user); 97 | 98 | let stripeCustomerId = user.stripe_customer_id; 99 | if (!stripeCustomerId) { 100 | stripeCustomerId = await createStripeCustomer(user); 101 | } 102 | 103 | const checkoutSession = await getOrCreateCheckoutSession( 104 | user, 105 | stripeCustomerId 106 | ); 107 | 108 | return { 109 | subscriptionStatus: "free_trial", 110 | trialDaysRemaining: trialDaysRemaining, 111 | purchaseSubscriptionLink: checkoutSession.url!, 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Engine Labs Logo 4 | 5 |
6 | GPT Billing API Template 7 |

8 |

Add billing/auth to your OpenAI GPT

9 | 10 | [![](https://img.shields.io/discord/1113845829741056101?logo=discord&style=flat)](https://discord.gg/QnytC3Y7Wx) 11 | 12 | Get [hosted version](https://share-eu1.hsforms.com/1s6stLQg1SqqSJzRjF-xOBg2b9ek1) 13 | 14 | # Features 15 | 16 | A starter template to write Fastify APIs for OpenAI GPTs with auth, billing and OpenAPI spec generation. 17 | 18 | - OAuth authentication with Clerk 19 | - Stripe billing 20 | - Prisma/SQLite for persistence 21 | - 100% TypeScript 22 | - Deploy wherever you like (we use Render) 23 | 24 | # Getting Started 25 | 26 | ```bash 27 | npm i 28 | npm run dev 29 | ``` 30 | 31 | ## Clerk Setup 32 | 33 | 1. Make a [Clerk](https://www.clerk.com) application 34 | 2. Get your publishable key and expose it as `CLERK_PUBLISHABLE_KEY` 35 | 3. Get your secret key and expose it as `CLERK_SECRET_KEY` 36 | 4. Make a Clerk webhook 37 | 1. Point it to the route `/webhooks/clerk` in this API wherever you've deployed it 38 | 2. Subscribe to the `user.created` event 39 | 5. Expose the webhook signing secret as `CLERK_WEBHOOK_SECRET` 40 | 6. Add a custom domain to your Clerk application that matches the `SERVER_URL` in your environment variables, which is where you have deployed this API 41 | 42 | ## Clerk OAuth Setup 43 | 44 | The app uses Clerk to provide oauth for you GPT. 45 | 46 | Run the following to create a Clerk oauth server on your production account: 47 | 48 | ```bash 49 | npm run clerk-oauth-create 50 | ``` 51 | 52 | This will write the file `./clerk_oauth.json`. Use these parameters in the GPT UI to create your oauth login for your GPT. 53 | 54 | OpenAI will then generate a callback URL for your GPT, copy this from the UI and run the following script to update 55 | your Clerk oauth to use it: 56 | 57 | ```bash 58 | npm run clerk-oauth-update https://chat.openai.com/aip/g-123/oauth/callback 59 | ``` 60 | 61 | Note you must use a production Clerk account with a custom domain, as the domain of your oauth must match that in your 62 | actions openapi spec. 63 | 64 | ## Stripe Setup 65 | 66 | 1. Get an API key from Stripe: https://dashboard.stripe.com/apikeys 67 | 2. Expose it as the env variable `STRIPE_SECRET_KEY` 68 | 3. Make a subscription product in the Stripe UI: https://dashboard.stripe.com/products/create 69 | 4. Get the resulting price ID and expose it as `STRIPE_PRICE_ID` 70 | 5. Make a webhook: https://dashboard.stripe.com/webhooks/create 71 | 1. Point it to the route `/webhooks/stripe` in this API wherever you've deployed it 72 | 2. Make it handle the following events: 73 | - `checkout.session.completed` 74 | - `customer.subscription.updated` 75 | - `customer.subscription.deleted` 76 | 6. Obtain the signing secret for your webhook and expose it as `STRIPE_WEBHOOK_SECRET` 77 | 78 | By default, protected API routes will require a subscription after 7 days. 79 | Change this by editing `TRIAL_DAYS` in `src/constants.ts` or by setting the `TRIAL DAYS` 80 | environment variable. 81 | 82 | ## Other Environment Variables 83 | 84 | `SERVER_URL` should point to wherever you've deployed this API, 85 | e.g. `https://example.com/api`. 86 | 87 | `DATABASE_URL` should point to wherever your database is. 88 | You will also need to update the `prisma.schema` file to use whatever database 89 | you have chosen, if it is not SQLite. 90 | 91 | `GPT_URL` should point to your GPT. It is used for redirection from billing pages. 92 | 93 | ## GPT configuration 94 | 95 | Point your GPT actions at the route `/docs/json` wherever you have deployed this API. 96 | 97 | If you want to set `x-openai-isConsequential` for any routes, you will instead need 98 | to copy the JSON at `/docs/json`, manually alter it, and put the resulting spec 99 | into your GPT actions. 100 | 101 | Tell your GPT about the billing in your `instructions` (aka prompt), for example: 102 | 103 | > After a user signs in via OAuth, check their billing subscription status and inform 104 | > them about the 7-day free trial for this service and display any generated links from actions. 105 | 106 | # Our GPTs 107 | 108 | Built using this backend template. 109 | 110 | [Database Builder](https://chat.openai.com/g/g-A3ueeULl8-database-builder) 111 | 112 | - Create and Execute Database Migrations: I can help you create and execute database migrations to update the structure of your PostgreSQL database. This includes adding tables, modifying columns, creating indexes, and more. 113 | - Rollback Migrations: If a recent migration didn't go as planned, I can help you roll it back to the previous state. 114 | - Retrieve Current Database Schema: I can fetch and display the current schema of your database, allowing you to see the structure of your tables, columns, and relationships. 115 | - Execute SQL Statements: You can ask me to execute specific SQL statements on your database. This is useful for querying data, updating records, deleting entries, and other database operations. 116 | - Provide Database URL: If you need to connect to the database using external tools or applications, I can provide you with the database URL. 117 | - Check Billing Subscription Status: After you sign in via OAuth, I will check your billing subscription status. I'll inform you about the 7-day free trial for this service and provide any necessary links for subscription management or purchase. 118 | --------------------------------------------------------------------------------