Building a Scalable Payment & Subscription System with Stripe and Adapty in Node.js


Hey everyone! Welcome to my first blog post, I hope it ages well and doesn’t end up as an embarrassing relic of my past.
I decided to kick things off with a topic that’s popular for giving developers headaches: Managing user subscriptions, payments, and web-to-app access. If you've ever struggled with handling subscriptions efficiently, you're not alone! In this post, I'll share how I built a streamlined payment and subscription system using Nodejs, Stripe, and Adapty
Why web integration with Adapty is helpful?
Adapty makes handling in-app purchases and subscriptions much easier, and integrating it with web payments unlocks additional benefits such as:
Automatic Access Across Devices: Users who purchase a subscription on the web can seamlessly access premium features when they install the app and log in.
Centralized Analytics: Get detailed insights on subscriptions, retention, and revenue from all platforms, all within a single Dashboard
Disclaimer: Adapty is my personal choice. Any similar tool providing web-to-app subscription tracking could be used.
System Overview: How It Works
Here’s a high-level breakdown of the payment and subscription flow:
User initiates a subscription purchase on the web using Stripe Checkout.
Stripe processes the payment and creates a checkout session.
Stripe sends an event to Adapty via webhook.
Adapty processes the event and forwards relevant subscription updates to our custom backend webhook.
Our backend handles the webhook, updating the database, and adjusting user access.
Users can query their subscription status through our API.
Database Design
Before diving into the implementation, let’s start with our schema design. Nothing gives me more clarity than a well-structured database schema—it helps visualize the entire system and keeps everything on track!
Users Table: Storing user identifiers ( Stripe, Adapty, user details, etc )
Transactions Table: Keeping payment details, statuses, and transaction history.
Subscriptions Table: The main source of truth about the user subscription data will be used to determine the user access level and process his permission accordingly.
model users_info {
id Int @id @default(autoincrement())
adapty_id String @unique
total_revenue Int @default(0)
subscriptions subscriptions[]
transactions transactions[]
}
model transactions {
id Int @id @default(autoincrement())
transaction_id String @unique
profile_id String
original_transaction_id String
purchase_date DateTime
original_purchase_date DateTime
price_usd Float
price_local Float
proceeds_usd Float
proceeds_local Float
tax_amount_usd Float
tax_amount_local Float
net_revenue_usd Float
net_revenue_local Float
store String
store_country String
environment String
event_datetime DateTime
profile_event_id String
created_at DateTime @default(now())
user users_info @relation(fields: [user_id], references: [id])
user_id String
}
model subscriptions {
id String @id @default(uuid())
user_id String
profile_id String
base_plan_id String?
vendor_product_id String
subscription_expires_at DateTime
consecutive_payments Int @default(1)
rate_after_first_year Boolean @default(false)
has_access Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
status String @default("inactive")
user users_info @relation(fields: [user_id], references: [id], onDelete: Cascade)
transactions transactions[]
@@unique([profile_id, vendor_product_id])
}
Creating a Stripe Checkout Session
Before handling webhooks, the first step is to create a Stripe Checkout session. This session collects payment details and initiates the subscription process.
Here's how we generate a checkout session in our backend which does the following:
Creates a checkout session for Stripe.
Stores metadata such as user ID, email, and name.
Supports trial periods for new users.
Returns a session URL where the user completes payment.
Once the session is completed, Stripe will trigger a webhook event, which we will handle next.
public createCheckoutSession = async (
customerId: string | null,
userId: string,
email: string,
priceId: string,
firstName: string | null,
lastName: string | null,
returnUrl?: string | null,
trial?: boolean,
) => {
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ["card"],
payment_method_collection: "always",
allow_promotion_codes: true,
line_items: [
{
price: priceId,
quantity: 1,
},
],
ui_mode: "embedded",
mode: "subscription",
return_url: returnUrl,
customer: customerId,
metadata: {
customer_user_id: userId,
email,
lastName,
firstName,
},
subscription_data: {
...(trial ? { trial_period_days: 14 } : {}),
payment_behavior: "allow_incomplete",
},
});
return session;
};
Subscriptions Webhook: Keeping Subscription Data in Sync
Webhooks play a crucial role in keeping our system up to date with real-time subscription events. When a user completes a purchase, renews a subscription, or cancels their plan, we need to ensure that this information is accurately reflected in our database by processing these events.
Key Actions:
Update the
subscriptions
table based on the webhook payload.Adjust user access levels if the subscription status changes.
This approach ensures that subscription changes are always in sync across the web and mobile apps. Now, let’s dive into the implementation!
export const updateSubscription = asyncErrorHandler(
async (req: Request, res: Response, next: NextFunction) => {
const payload = {
...req.body,
...req.body.event_properties,
} as AdaptyWebhookPayload;
if (!payload.customer_user_id) {
return next(
new AppError(
"Missing customer_user_id in webhook payload",
HttpStatusCode.BadRequest,
ErrorStatus.BadRequest,
),
);
}
switch (payload.event_type) {
case AdaptyEvents.TrialStarted:
await handleTrialStart(payload);
break;
case AdaptyEvents.SubscriptionStarted:
await handleSubscriptionStart(payload);
break;
case AdaptyEvents.SubscriptionRenewed:
await handleSubscriptionRenewal(payload);
break;
case AdaptyEvents.SubscriptionExpired:
await handleSubscriptionExpiration(payload);
break;
case AdaptyEvents.SubscriptionRenewalCancelled:
await handleSubscriptionCancellation(payload);
break;
case AdaptyEvents.TrialRenewalCancelled:
await handleTrialRenewalCancelled(payload);
break;
case AdaptyEvents.TrialExpired:
await handleTrialExpiration(payload);
break;
default:
logger.warn(`Unhandled webhook event type: ${payload.event_type}`);
break;
logger.info(
"Webhook processed",
JSON.stringify({
event_type: payload.event_type,
user_id: payload.customer_user_id,
transaction_id: payload.transaction_id,
timestamp: new Date().toISOString(),
}),
);
res.send({
status: "success",
message: "Webhook processed successfully",
});
},
);
Querying Subscription Status via API
Once the database has been updated with the latest subscription information, users need a way to check their current subscription status. This is where the API comes in.
The following function queries the subscriptions table for the given user, including transaction history and key user details:
export const getUserSubscriptionWithTransactions = async (userId: string) => {
const subscriptionWithTransactions = await prisma.subscriptions.findFirst({
where: {
user_id: userId,
},
include: {
transactions: {
orderBy: {
created_at: "desc",
},
},
user: {
select: {
id: true,
email: true,
adapty_id: true,
total_revenue: true,
},
},
},
});
if (!subscriptionWithTransactions) {
throw new AppError(
"No subscription found for this user",
HttpStatusCode.NotFound,
ErrorStatus.ResourceNotFound,
);
}
return subscriptionWithTransactions;
};
Conclusion
By integrating Stripe Checkout, Adapty, and a structured backend webhook handler, we’ve built a seamless subscription management system that ensures accurate payments and user access tracking.
🚀 Next Steps: Consider adding retry logic, notification emails, and a subscription dashboard.
Got questions? Drop a comment below!
Subscribe to my newsletter
Read articles from Yazan Alkhatib directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Yazan Alkhatib
Yazan Alkhatib
I'm a Senior Software Engineer with 6 years of experience, specializing in deploying production workloads on Kubernetes, configuring robust CI/CD pipelines, and leveraging AWS cloud services. Throughout my career, I've honed my expertise across various JavaScript frameworks and cloud technologies. I'm passionate about sharing my knowledge to help developers land rewarding tech roles, excel in their careers, and confidently navigate the cloud computing landscape.