The IHP Stripe integration allows you to accept payments and manage subscriptions within your IHP application. It provides a complete solution for SaaS billing with support for checkout sessions, webhooks, subscriptions, and invoices.
The IHP Stripe integration is available with IHP Pro and IHP Business. By switching to Pro, you’re supporting the sustainable development of IHP.
This guide assumes you’re familiar with the basics of the Stripe API. If you’re new to Stripe, review their documentation first.
Installation
Add the dependency
Add ihp-stripe to the haskellDeps in your default.nix:let
...
haskellEnv = import "${ihp}/NixSupport/default.nix" {
ihp = ihp;
haskellDeps = p: with p; [
# ...
ihp-stripe
];
...
Rebuild environment
Rebuild your environment: Import Stripe config
Add the import to your Config/Config.hs:module Config where
-- ...
import IHP.Stripe.Config
Initialize Stripe
Add a call to initStripe in your Config/Config.hs:module Config where
import IHP.Prelude
import IHP.Environment
import IHP.FrameworkConfig
import IHP.Stripe.Config
config :: ConfigBuilder
config = do
option Development
option (AppHostname "localhost")
initStripe
Configuration
Stripe API Keys
Open your start script and add the Stripe environment variables:
# Add these before the `RunDevServer` call at the end of the file
export STRIPE_WEBHOOK_SECRET_KEY="whsec_..."
export STRIPE_PUBLIC_KEY="pk_test_..."
export STRIPE_SECRET_KEY=""
# Finally start the dev server
RunDevServer
Replace the placeholder values with your actual Stripe API keys from the Stripe Dashboard. Use test-mode keys for development.
Keep STRIPE_SECRET_KEY empty initially. We’ll configure it when setting up webhooks.
Setting Up Subscriptions
Database Schema
Plans Table
Even with a single pricing plan, it’s helpful to model plans in your database:
CREATE TABLE plans (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
stripe_price_id TEXT NOT NULL
);
Create a product and price in the Stripe Dashboard, then insert the plan using the IHP Data Editor with the Stripe price ID (looks like price_...).
Subscriptions Table
CREATE TABLE subscriptions (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
user_id UUID NOT NULL,
starts_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
ends_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
is_active BOOLEAN DEFAULT true NOT NULL,
stripe_subscription_id TEXT NOT NULL,
plan_id UUID NOT NULL,
quantity INT DEFAULT 1 NOT NULL
);
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_ref_plan_id
FOREIGN KEY (plan_id) REFERENCES plans (id) ON DELETE NO ACTION;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_ref_user_id
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE NO ACTION;
Users Table Updates
Add Stripe-related fields to your users table:
CREATE TABLE users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
-- ...,
stripe_customer_id TEXT DEFAULT NULL,
plan_id UUID DEFAULT NULL,
subscription_id UUID DEFAULT NULL
);
CREATE INDEX users_stripe_customer_id_index ON users (stripe_customer_id);
CREATE INDEX users_plan_id_index ON users (plan_id);
CREATE INDEX users_subscription_id_index ON users (subscription_id);
ALTER TABLE users ADD CONSTRAINT users_ref_plan_id
FOREIGN KEY (plan_id) REFERENCES plans (id) ON DELETE NO ACTION;
ALTER TABLE users ADD CONSTRAINT users_ref_subscription_id
FOREIGN KEY (subscription_id) REFERENCES subscriptions (id) ON DELETE NO ACTION;
Checkout Sessions Controller
Create the checkout sessions controller in Web/Types.hs:
data CheckoutSessionsController
= CheckoutSuccessAction
| CheckoutCancelAction
| CreateCheckoutSessionAction
deriving (Eq, Show, Data)
Create Web/Controller/CheckoutSessions.hs:
module Web.Controller.CheckoutSessions where
import Web.Controller.Prelude
import qualified IHP.Stripe.Types as Stripe
import qualified IHP.Stripe.Actions as Stripe
instance Controller CheckoutSessionsController where
beforeAction = ensureIsUser
action CreateCheckoutSessionAction = do
-- You can later customize this, e.g. you could pass
-- the planId via a form on the pricing page
plan <- query @Plan |> fetchOne
stripeCheckoutSession <- Stripe.send Stripe.CreateCheckoutSession
{ successUrl = urlTo CheckoutSuccessAction
, cancelUrl = urlTo CheckoutCancelAction
, mode = "subscription"
, paymentMethodTypes = ["card"]
, customer = currentUser.stripeCustomerId
, lineItem = Stripe.LineItem
{ price = plan.stripePriceId
, quantity = 1
, taxRate = Nothing
, adjustableQuantity = Nothing
}
, metadata =
[ ("userId", tshow currentUserId)
, ("planId", tshow plan.id)
]
}
redirectToUrl stripeCheckoutSession.url
action CheckoutSuccessAction = do
plan <- fetchOne currentUser.planId
setSuccessMessage ("You're on the " <> plan.name <> " plan now!")
redirectToPath "/"
action CheckoutCancelAction = do
redirectToPath "/"
Enable routing in Web/Routes.hs:
instance AutoRoute CheckoutSessionsController
Enable the controller in Web/FrontController.hs:
import Web.Controller.CheckoutSessions
instance FrontController WebApplication where
controllers =
[ startPage StartpageAction
-- ...
, parseRoute @CheckoutSessionsController
]
Add a payment button to your pricing page:
<form method="POST" action={CreateCheckoutSessionAction} data-disable-javascript-submission={True}>
<button class="btn btn-primary">Subscribe to Plan</button>
</form>
Webhooks
Webhooks notify your application about Stripe events like successful payments.
Webhook Controller
Create Web/Controller/StripeWebhook.hs:
module Web.Controller.StripeWebhook where
import Web.Controller.Prelude
import IHP.Stripe.Actions
import IHP.Stripe.Webhook
import IHP.Stripe.Types
instance StripeEventController where
on CheckoutSessionCompleted { checkoutSessionId, subscriptionId, customer, metadata } = do
let userId :: Maybe (Id User) = metadata
|> lookup "userId"
|> fmap textToId
let planId :: Id Plan = metadata
|> lookup "planId"
|> fmap textToId
|> fromMaybe (error "Requires plan id")
user <- fetchOneOrNothing userId
case user of
Just user -> do
subscription <- newRecord @Web.Controller.Prelude.Subscription
|> set #userId user.id
|> set #stripeSubscriptionId subscriptionId
|> set #planId planId
|> set #quantity 1
|> createRecord
user
|> setJust #subscriptionId subscription.id
|> setJust #planId planId
|> setJust #stripeCustomerId customer
|> updateRecord
pure ()
Nothing -> do
putStrLn "Stripe CheckoutSessionCompleted: User not found."
pure ()
on InvoiceFinalized { subscriptionId, stripeInvoiceId, invoiceUrl, invoicePdf, createdAt, total, currency } = do
pure () -- We'll handle this later
on OtherEvent = do
putStrLn "Skipping OtherEvent"
Add routing in Web/Routes.hs:
Enable in Web/FrontController.hs:
import Web.Controller.StripeWebhook
import IHP.Stripe.Types
instance FrontController WebApplication where
controllers =
[ startPage StartpageAction
-- ...
, parseRoute @StripeWebhookController
]
Testing Webhooks
Install the Stripe CLI and forward events to your local server:
stripe listen --forward-to localhost:8000/StripeWebhook
Copy the webhook secret key (starts with whsec_) and update your start script:
export STRIPE_WEBHOOK_SECRET_KEY="whsec_..."
Restart your application.
Handling Cancellations
Add handlers for subscription updates and deletions:
on CustomerSubscriptionUpdated { subscription = stripeSubscription } = do
maybeSubscription <- query @Web.Controller.Prelude.Subscription
|> filterWhere (#stripeSubscriptionId, stripeSubscription.id)
|> fetchOneOrNothing
case maybeSubscription of
Just subscription -> do
subscription
|> set #endsAt (if stripeSubscription.cancelAtPeriodEnd
then stripeSubscription.currentPeriodEnd
else Nothing)
|> updateRecord
pure ()
Nothing -> pure ()
on CustomerSubscriptionDeleted { subscriptionId } = do
maybeSubscription <- query @Web.Controller.Prelude.Subscription
|> filterWhere (#stripeSubscriptionId, subscriptionId)
|> fetchOneOrNothing
case maybeSubscription of
Just subscription -> do
now <- getCurrentTime
subscription
|> set #endsAt now
|> set #isActive False
|> updateRecord
user <- fetch subscription.userId
user
|> set #planId Nothing
|> set #subscriptionId Nothing
|> updateRecord
pure ()
Nothing -> pure ()
Billing Portal
Integrate Stripe’s customer billing portal:
import qualified IHP.Stripe.Actions as Stripe
import qualified IHP.Stripe.Types as Stripe
action OpenBillingPortalAction = do
subscription <- fetchOne currentUser.subscriptionId
stripeSubscription <- Stripe.send Stripe.RetrieveSubscription
{ id = subscription.stripeSubscriptionId }
billingPortal <- Stripe.send Stripe.CreateBillingPortalSession
{ customer = stripeSubscription.customer
, returnUrl = urlTo StartpageAction
}
redirectToUrl billingPortal.url
Link to it with a form:
<form method="POST" action={OpenBillingPortalAction} data-disable-javascript-submission={True}>
<button type="submit" class="btn btn-primary">Change Payment Details</button>
</form>
Invoices
Schema
Add an invoices table:
CREATE TABLE invoices (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
subscription_id UUID NOT NULL,
stripe_invoice_id TEXT NOT NULL,
invoice_url TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
invoice_pdf TEXT NOT NULL,
total INT NOT NULL,
currency TEXT NOT NULL
);
ALTER TABLE invoices ADD CONSTRAINT invoices_ref_subscription_id
FOREIGN KEY (subscription_id) REFERENCES subscriptions (id) ON DELETE NO ACTION;
Webhook Handler
Implement the InvoiceFinalized handler:
on InvoiceFinalized { subscriptionId, stripeInvoiceId, invoiceUrl, invoicePdf, createdAt, total, currency } = do
subscriptionMaybe <- query @Subscription
|> filterWhere (#stripeSubscriptionId, subscriptionId)
|> fetchOneOrNothing
case subscriptionMaybe of
Just subscription -> do
existingInvoice <- query @Invoice
|> filterWhere (#stripeInvoiceId, stripeInvoiceId)
|> fetchOneOrNothing
case existingInvoice of
Just invoice -> do
invoice
|> set #invoiceUrl invoiceUrl
|> set #createdAt createdAt
|> set #invoicePdf invoicePdf
|> set #total (fromInteger total)
|> set #currency currency
|> updateRecord
pure ()
Nothing -> do
invoice <- newRecord @Invoice
|> set #subscriptionId subscription.id
|> set #stripeInvoiceId stripeInvoiceId
|> set #invoiceUrl invoiceUrl
|> set #createdAt createdAt
|> set #invoicePdf invoicePdf
|> set #total (fromInteger total)
|> set #currency currency
|> createRecord
pure ()
pure ()
Nothing -> do
putStrLn "Stripe InvoiceFinalized: Subscription not found."
pure ()
Displaying Invoices
Create an invoices controller:
action InvoicesAction = do
subscriptions <- query @Subscription
|> filterWhere (#userId, currentUserId)
|> fetch
invoices <- query @Invoice
|> orderByDesc #createdAt
|> filterWhereIn (#subscriptionId, ids subscriptions)
|> fetch
render IndexView { .. }
Render invoices in your view:
renderInvoice :: Invoice -> Html
renderInvoice invoice = [hsx|
<div class="card d-flex flex-row py-3 px-1 mb-1">
<div class="col">
<a>{invoice.createdAt |> date}</a>
</div>
<div class="col">
<a href={invoice.invoiceUrl} target="_blank">Subscription</a>
</div>
<div class="col">
<a>{renderPrice invoice.currency invoice.total}</a>
</div>
<div class="col-xs mr-3">
<a href={invoice.invoicePdf}>Download</a>
</div>
</div>
|]
renderPrice :: Text -> Int -> Text
renderPrice "eur" amount = show (fromIntegral(amount)/100) <> "€"
renderPrice "usd" amount = "$" <> show (fromIntegral(amount)/100)
renderPrice code amount = show (fromIntegral(amount)/100) <> toUpper code
Best Practices
- Use Test Mode: Always use test mode keys during development
- Handle Webhooks: Webhooks are the reliable way to track payment events
- Validate Metadata: Always validate webhook metadata before processing
- Tax Compliance: Use Stripe Tax for automatic VAT collection
- Error Handling: Implement proper error handling for all Stripe API calls
See Also