These are some examples of using the Stripe API in a Clojure/Biff app, taken verbatim from Yakread. For more details, see the Stripe documentation. For the most part I recommend using the REST API directly rather than going through the Java SDK.
Yakread has an advertising page that lets you connect a credit card via Stripe checkout. The card is charged later, after your advertisement has run.
Here's some of the code for the advertising page:
(defn create-customer! [{:keys [biff/secret user] :as ctx}]
(let [id (-> (http/post "https://api.stripe.com/v1/customers"
{:basic-auth [(secret :stripe/api-key)]
:form-params {:email (:user/email user)}
:as :json})
:body
:id)]
(biff/submit-tx ctx
[{:db/doc-type :ad
:db.op/upsert {:ad/user (:xt/id user)}
:ad/customer-id id
:ad/state [:db/default :pending]
:ad/balance [:db/default 0]
:ad/recent-cost [:db/default 0]
:ad/updated-at :db/now}])
id))
(defn add-payment-method [{:keys [biff/secret biff/base-url user ad] :as ctx}]
(let [customer-id (or (:ad/customer-id ad)
(create-customer! ctx))
response (http/post "https://api.stripe.com/v1/checkout/sessions"
{:basic-auth [(secret :stripe/api-key)]
:multi-param-style :array
:form-params {:payment_method_types ["card"]
:mode "setup"
:customer customer-id
:success_url (str base-url "/fiddlesticks/payment-method?session-id={CHECKOUT_SESSION_ID}")
:cancel_url (str base-url "/advertise")}
:as :json})
{:keys [url id]} (:body response)]
(biff/submit-tx ctx
[{:db/doc-type :ad
:db.op/upsert {:ad/user (:xt/id user)}
:ad/session-id id
:ad/updated-at :db/now}])
{:status 303
:headers {"Location" url}}))
(defn receive-payment-method [{:keys [biff/secret biff/db params] :as ctx}]
(let [{:keys [session-id]} params
pm (-> (http/get (str "https://api.stripe.com/v1/checkout/sessions/" session-id)
{:basic-auth [(secret :stripe/api-key)]
:multi-param-style :array
:as :json
:query-params {:expand ["setup_intent" "setup_intent.payment_method"]}})
:body
:setup_intent
:payment_method)]
(when-some [ad-id (biff/lookup-id db :ad/session-id session-id)]
(biff/submit-tx ctx
[{:db/doc-type :ad
:db/op :update
:xt/id ad-id
:ad/session-id :db/dissoc
:ad/payment-method (:id pm)
:ad/card-details (select-keys (:card pm) [:brand :last4 :exp_year :exp_month])
:ad/updated-at :db/now}])))
{:status 303
:headers {"Location" "/advertise"}})
(defn delete-payment-method [{:keys [biff/secret ad] :as ctx}]
(let [ctx (update ctx :ad dissoc :ad/payment-method :ad/card-details)]
(biff/submit-tx ctx
[{:db/doc-type :ad
:db/op :update
:xt/id (:xt/id ad)
:ad/payment-method :db/dissoc
:ad/card-details :db/dissoc
:ad/updated-at :db/now}])
(http/post (str "https://api.stripe.com/v1/payment_methods/"
(:ad/payment-method ad)
"/detach")
{:basic-auth [(secret :stripe/api-key)]})
[:<>
(view-payment-method ctx)]))
...
(def module
{:routes [["/fiddlesticks" {:middleware [mid/wrap-signed-in
wrap-ad]}
["/payment-method" {:get receive-payment-method
:post add-payment-method
:delete delete-payment-method}]
...]]})
When you click "Add a payment method", we hit the add-payment-method
request handler which creates a customer object
in Stripe and then redirects you to Stripe's checkout page. After you enter your credit card, Stripe redirects you to
the receive-payment-method
request handler which stores the payment method ID, which is used later for charging your
credit card.
(Some of the URLs have fiddlesticks
in them because ad blockers don't like AJAX requests that hit URLs that contain
the text advertise
.)
These are some of the functions I use to submit charges:
(defn create-payment-intent! [{:keys [biff/secret ::charge dry-run]}]
(let [receipt-email (-> charge :ad.credit/ad :ad/user :user/email)
params {:amount (:ad.credit/amount charge)
:currency "usd"
:confirm true
:customer (-> charge :ad.credit/ad :ad/customer-id)
:payment_method (-> charge :ad.credit/ad :ad/payment-method)
:off_session true
:description "Yakread advertising"
:receipt_email receipt-email
:metadata {:charge_id (str (:xt/id charge))}}]
(when (some? receipt-email)
(if dry-run
(do
(println "creating payment intent for" (-> charge :ad.credit/ad :ad/title))
(biff/pprint params)
(println))
(biff/catchall-verbose
(http/post "https://api.stripe.com/v1/payment_intents"
{:basic-auth [(secret :stripe/api-key) ""]
:headers {"Idempotency-Key" (str (:xt/id charge))}
:flatten-nested-form-params true
:form-params params}))))))
(defn get-charge-status [{:keys [biff/secret ::charge]}]
(->> (http/get "https://api.stripe.com/v1/payment_intents"
{:basic-auth [(secret :stripe/api-key) ""]
:flatten-nested-form-params true
:as :json
:query-params {:limit 100
:customer (-> charge :ad.credit/ad :ad/customer-id)
:created {:gt (-> charge
:ad.credit/created-at
inst-ms
(quot 1000)
str)}}})
:body
:data
(filter (fn [{:keys [metadata]}]
(= (str (:xt/id charge))
(:charge_id metadata))))
first
:status))
Yakread also has a premium plan for users where you can pay a monthly subscription to remove ads, also managed by Stripe checkout. Yakread sends you to a page hosted on Stripe where you can enter your credit card and select a plan (or change/cancel your plan if you've previously signed up), then Yakread receives webhook messages from Stripe whenever your plan changes:
(ns com.yakread.feat.app.settings.premium
(:require [com.biffweb :as biff]
[com.yakread.middleware :as mid]
[clj-http.client :as http]
[clojure.stacktrace :as st]
[ring.util.request :as ring-req]
[com.yakread.util :as util]
[com.yakread.ui :as ui]
[clojure.tools.logging :as log])
(:import [com.stripe.net Webhook]))
(defn settings [{:keys [user params] :as ctx}]
(let [param-plan (some-> (:upgraded params) not-empty keyword)
[plan active] (if param-plan
[param-plan true]
[(:user/plan user) (util/plan-active? user)])]
[:<>
(ui/heading "Yakread Premium")
(if active
[:<>
[:div
(if (:user/cancel-at user)
[:<> "You're on the premium plan until "
(biff/format-date (:user/cancel-at user) "d MMMM yyyy")
". After that, you'll be downgraded to the free plan. "]
[:<>
"You're on the "
(case plan
:quarter "$30 / 3 months"
:annual "$60 / 12 months"
:else "premium")
" plan. "])
(biff/form
{:action "/premium/manage"
:hx-boost "false"
:class "inline"}
[:button.link {:type "submit"} "Manage your subscription"])
"."]
[:.h-3]]
[:<>
[:div "Upgrade to a premium plan without ads:"]
[:.h-6]
[:div {:class '[flex
justify-center
flex-col
sm:flex-row
gap-4
sm:gap-12
items-center]}
(biff/form
{:action "/premium/upgrade"
:hx-boost "false"
:hidden {:plan "quarter"}}
[:button {:class '[btn
"min-w-[150px]"]}
"$30 / 3 months"])
(biff/form
{:action "/premium/upgrade"
:hx-boost "false"
:hidden {:plan "annual"}}
[:button {:class '[btn
"min-w-[150px]"]}
"$60 / 12 months"])]
[:.h-6]])
[:div "Are there other features you'd like to see on the premium plan? "
[:a.link {:href "/feedback"} "Send me your feedback"] "."]]))
(defn create-customer! [{:keys [biff/secret user] :as ctx}]
(let [id (get-in (http/post "https://api.stripe.com/v1/customers"
{:basic-auth [(secret :stripe/api-key)]
:form-params {:email (:user/email user)}
:as :json})
[:body :id])]
(biff/submit-tx ctx
[{:db/doc-type :user
:db/op :update
:xt/id (:xt/id user)
:user/customer-id id}])
id))
(defn manage [{:keys [biff/base-url biff/secret user] :as ctx}]
{:status 303
:headers {"location" (get-in (http/post "https://api.stripe.com/v1/billing_portal/sessions"
{:basic-auth [(secret :stripe/api-key)]
:form-params {:customer (:user/customer-id user)
:return_url (str base-url "/settings")}
:as :json})
[:body :url])}})
(defn upgrade [{:keys [biff/base-url
biff/secret
user
params
stripe/quarter-price-id
stripe/annual-price-id]
:as ctx}]
(if (util/plan-active? user)
(manage ctx)
(let [customer-id (or (:user/customer-id user)
(create-customer! ctx))
price-id (if (= (:plan params) "quarter")
quarter-price-id
annual-price-id)
session-url (get-in (http/post "https://api.stripe.com/v1/checkout/sessions"
{:basic-auth [(secret :stripe/api-key)]
:multi-param-style :array
:form-params {:mode "subscription"
:allow_promotion_codes true
:customer customer-id
"line_items[0][quantity]" 1
"line_items[0][price]" price-id
:success_url (str base-url "/settings?upgraded=" (:plan params))
:cancel_url (str base-url "/settings")}
:as :json})
[:body :url])]
{:status 303
:headers {"location" session-url}})))
(defn update-subscription! [{:keys [biff/db
stripe/quarter-price-id
body-params]
:as ctx}]
(let [{:keys [customer items cancel_at]} (get-in body-params [:data :object])
price-id (get-in items [:data 0 :price :id])
plan (if (= quarter-price-id price-id)
:quarter
:annual)
user-id (biff/lookup-id db :user/customer-id customer)]
(biff/submit-tx ctx
[{:db/doc-type :user
:db/op :update
:xt/id user-id
:user/plan plan
:user/cancel-at (if cancel_at
(java.util.Date. (* cancel_at 1000))
:db/dissoc)}])))
(defn delete-subscription! [{:keys [biff/db
stripe/quarter-price-id
body-params]
:as ctx}]
(let [{:keys [customer]} (get-in body-params [:data :object])
user-id (biff/lookup-id db :user/customer-id customer)]
(biff/submit-tx ctx
[{:db/doc-type :user
:db/op :update
:xt/id user-id
:user/plan :db/dissoc
:user/cancel-at :db/dissoc}])))
(defn stripe-webhook [{:keys [body-params] :as ctx}]
(log/info "received stripe event" (:type body-params))
(case (:type body-params)
"customer.subscription.created" (update-subscription! ctx)
"customer.subscription.updated" (update-subscription! ctx)
"customer.subscription.deleted" (delete-subscription! ctx)
nil)
{:status 200
:body ""})
(defn wrap-stripe-event [handler]
(fn [{:keys [biff/secret headers] :as req}]
(if (and (= "/stripe/webhook" (:uri req))
(secret :stripe/webhook-secret))
(try
(let [body-str (ring-req/body-string req)]
(Webhook/constructEvent body-str
(headers "stripe-signature")
(secret :stripe/webhook-secret))
(handler (assoc req
:body (-> body-str
(.getBytes "UTF-8")
(java.io.ByteArrayInputStream.)))))
(catch Exception e
(st/print-stack-trace e)
{:status 400
:body ""}))
(handler req))))
(def features
{:routes [["/premium" {:middleware [mid/wrap-signed-in]}
["/upgrade" {:post upgrade}]
["/manage" {:post manage}]]]
:api-routes [["/stripe/webhook" {:post stripe-webhook}]]})
And in the main namespace:
(def handler (-> (biff/reitit-handler {:router router :on-error ui/on-error-page})
biff/wrap-base-defaults
premium/wrap-stripe-event))
wrap-stripe-event
uses the Stripe Java SDK to verify that the webhook message is authentic. It needs the request body
as a string. We have to make wrap-stripe-event
run before any other middleware because otherwise the body input stream
would already be consumed. So we read the input stream, turn it into a string, and then create another input stream for
the subsequent middleware.