TLDR:
You can create a free Stripe sandbox account in about 10 seconds by registering here (email, name, password), and look around to see if it fits your needs.
If you want to build an e-commerce application, but you’re not a programmer - contact us.
If you’re interested in building a Stripe integration yourself - this is the guide for you. Here’s the final repo.
Why Stripe?
Setting up Stripe is quick and easy, you can start coding your integration pretty much immediately. Beside this essential feature for this guide, Stripe is also very popular and well supported. They’re pretty good with money apparently - it’s the most highly valued private tech company in the US. Among others, Elon Musk has seen Stripe’s potential back in 2013 already with his sizeable investment - despite owning Stripe’s direct competitor in the payment processing segment, PayPal.
All in all, selling stuff over the internet is pretty easy, as long as the middleman gets their cut (spoiler: the middleman always gets their cut). Stripe’s fees are 1.4% of the transaction value for European cards, and 2.9% for non-European cards, plus a small constant fee (about 0.25 EUR). It is visibly higher than the standard fee of credit card giants Visa and MasterCard (around 1% EU / 1.75% non-EU), but the service Stripe provides might as well be worth it.
There are many other payment processors that excel in different fields and countries, but knowing fundamental principles of Stripe and having basic documentation reading skills, you can easily utilize other services, like PayPal or Zuora, as your payment processor. Especially in case of countries that use currencies outside of the top 4 most traded worldwide (USD, EUR, JPY, GBP), even a few hours spent researching the most profitable option for your needs will be most likely time well spent. When it comes to automatic currency exchange - avoid like fire (fees of about 3% and higher, on top of spread and regular transaction fees).
Setup your app
To start our integration, let’s create a new Phoenix project (without a database):
mix phx.new sv --module SV --no-ecto
Looking at Stripe’s REST API docs, despite the API just using simple HTTP requests, they really want you to use an existing library, to the point of hiding details of how requests’ headers should look like. This is probably for your own good. In this project we’ll use Stripe’s approved library Stripy for sending requests to Stripe’s API.
Add it to mix.exs
:
# mix.exs
def deps do
[{:stripy, "~> 2.0"}]
end
run mix deps.get
, and add Stripy configuration to config/config.exs
:
# config/config.exs
config :stripy,
secret_key: {:system, "STRIPE_SECRET_KEY"},
public_key: {:system, "STRIPE_PUBLIC_KEY"},
endpoint: "https://api.stripe.com/v1/",
version: "2024-12-18.acacia",
httpoison: [recv_timeout: 5000, timeout: 8000]
Remember to set your local environment variables using the pair of keys for your Stripe sandbox - you can find them on the dashboard page:
Now let’s create a context module Stripe
for functions that will communicate with the Stripe REST API, and a resource the user will see and interact with - a Product
(without a schema, as all the data will reside in Stripe anyway):
mix phx.gen.context Stripe Product products --no-schema
In the new context module SV.Stripe
we won’t use functions update_product
, delete_product
and change_product
- delete them, along with non-existent references to the repository, so the code compiles:
import Ecto.Query, warn: false
alias SV.Repo
alias SV.Stripe.Product
Our sandbox is currently very empty. We want to sell stuff, but there’s nothing to sell - we have to start by creating some products. We’ll do it following the products API docs, and add functions to the SV.Stripe
context:
# lib/sv/stripe.ex
def create_product(%{} = attrs) do
Stripy.req(:post, "products", attrs)
|> decode()
end
defp decode({:ok, %HTTPoison.Response{body: body}}), do: Poison.decode(body)
defp decode(error), do: error
Now start up your app iex -S mix
and create a couple of products:
SV.Stripe.create_product(%{
id: "product_landfill",
name: "Landfill 1 tonne",
description: "1 tonne of dirt",
shippable: true
})
SV.Stripe.create_product(%{
id: "product_laundromat",
name: "Laundromat access",
description: "A code allowing one time or a recurring subscription access to the laundromat",
shippable: false
})
SV.Stripe.create_product(%{
name: "²³⁵U",
active: false
})
SV.Stripe.create_product(%{
id: "product_subscription_1",
name: "Basic access subscription",
description: "Access to super interesting web stuff",
shippable: false
})
SV.Stripe.create_product(%{
id: "product_subscription_2",
name: "Premium access subscription",
description: "Super access to mega interesting web stuff",
shippable: false
})
You can now take a look at your newly created products in your dashboard. Of course you can also create products manually from the Stripe dashboard itself, but for the sake of this guide, we both saved some time navigating menus, and also learned a simple API use case.
We have our products now, but there are no prices anywhere. Well, one product can have multiple prices, and each price has multiple properties. Here’s a quick overview of the core API objects:
• Customers are your buyers. The Customer object stores information like names, email addresses, and payment methods (credit cards, debit cards, etc.).
• Products are what you sell.
• Prices represent how much and how often you charge for a product. You can define multiple prices for a product so you can charge different amounts based on currency or interval (monthly, yearly, etc.).
• Subscriptions represent your Customers’ access to a product and require you to create a customer and payment method. The status of a subscription indicates when to provision access to your service for a customer.
• Stripe generates invoices when it’s time to bill a customer for a subscription. Invoices have line items, tax rates, and the total amount owed by a customer.
• Payment Intents represent the state of all attempts to pay an invoice.
In our case, the “Laundromat access” product is a perfect candidate to have multiple price objects - it can be a one time access product, and it can also be a subscription with multiple intervals, or even more.
Let’s implement a function to create a price and fetch prices for a product:
# lib/sv/stripe.ex
def create_price(%{} = attrs) do
Stripy.req(:post, "prices", attrs)
|> decode()
end
def list_prices_for_product(product_id) do
Stripy.req(:get, "prices", %{product: product_id, active: true})
|> decode()
end
and create some example prices for our existing products:
SV.Stripe.create_price(%{
product: "product_landfill",
currency: "EUR",
unit_amount: 10000
})
SV.Stripe.create_price(%{
product: "product_laundromat",
currency: "EUR",
unit_amount: 500
})
SV.Stripe.create_price(%{
product: "product_laundromat",
currency: "EUR",
unit_amount: 1500,
"recurring[interval]": "month"
})
SV.Stripe.create_price(%{
product: "product_laundromat",
currency: "EUR",
unit_amount: 12000,
"recurring[interval]": "year"
})
SV.Stripe.create_price(%{
product: "product_subscription_1",
currency: "EUR",
unit_amount: 1000,
"recurring[interval]": "month"
})
SV.Stripe.create_price(%{
product: "product_subscription_2",
currency: "EUR",
unit_amount: 2000,
"recurring[interval]": "month"
})
Note that there are two products for seemingly one service of different tiers: Basic access subscription
and Premium access subscription
. This is the recommended way to handle fixed-price subscriptions with different tiers - as stated in Stripe docs: https://stripe.com/docs/billing/subscriptions/model#package-standard-pricing. You can achieve the same result using only one product with tiered quantity-based pricing, but that’s just a big mess.
Show your products catalog to users
First add a function that fetches the list of products from Stripe API:
# lib/sv/stripe.ex
def list_products do
Stripy.req(:get, "products", %{active: true})
|> decode()
end
We won’t worry about it in the case of this guide, but the list is paginated using limit
and starting_after
params, as one would expect from an average REST API. Remember to only display active
records to users too.
Next generate the controller and templates:
mix phx.gen.html Stripe Product products --no-schema --no-context
We’ll only need index
and show
actions for products - the remaining ones should be deleted (as the scaffolded code incorrectly assumes a SV.Stripe.Product
schema exists).
We also need to update routes manually:
# lib/sv_web/router.ex
scope "/", SVWeb do
pipe_through :browser
resources "/", ProductController, only: [:index, :show]
end
Then update the controller’s actions:
# lib/sv_web/controllers/product_controller.ex
def index(conn, _params) do
{:ok, %{"data" => products}} = Stripe.list_products()
render(conn, "index.html", products: products)
end
def show(conn, %{"id" => id}) do
{:ok, product} = Stripe.get_product(id)
{:ok, %{"data" => prices}} = Stripe.list_prices_for_product(product["id"])
render(conn, "show.html", product: product, prices: prices)
end
Add an ugly table to display available products - lib/sv_web/templates/product/index.html.heex
:
<table>
<thead>
<tr>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for product <- @products do %>
<tr>
<td><%= product["name"] %></td>
<td>
<span><%= link "Details", to: Routes.product_path(@conn, :show, product["id"]) %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
Now you can boot up the server (iex -S mix phx.server
) and see if it actually works: http://localhost:4000
And a little product details page that also shows available price plans. We’ll add a way to buy products on this page later.
lib/sv_web/templates/product/show.html.heex
:
<h1><%= @product["name"] %></h1>
<p><%= @product["description"] %></p>
<h3>Price plans</h3>
<ul>
<%= for price <- @prices do %>
<li>
<span>
<%= if price["type"] == "one_time", do: "One time payment of " %>
<%= if price["recurring"]["interval"] == "month", do: "Monthly subscription of " %>
<%= if price["recurring"]["interval"] == "year", do: "Yearly subscription of " %>
</span>
<span><b><%= "#{price["unit_amount_decimal"] |> String.split_at(-2) |> Tuple.to_list() |> Enum.join(".")} #{String.upcase(price["currency"])}" %></b></span>
</li>
<% end %>
</ul>
<span><%= link "Back", to: Routes.product_path(@conn, :index) %></span>
Who’s the customer?
This is just a test app - for the sake of simplicity let’s just store the user’s identity in cookies, as a randomly generated email. Create a plug for it:
# lib/sv_web/plugs/set_user.ex
defmodule SV.Plugs.RequireUser do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :user_email) do
nil ->
random_email =
"#{:crypto.strong_rand_bytes(10) |> Base.url_encode64 |> binary_part(0, 10)}@example.com"
conn
|> put_session(:user_email, random_email)
|> assign(:user_email, random_email)
user_email ->
conn
|> assign(:user_email, user_email)
end
end
end
and add it to the :browser
pipeline in router.ex
:
# lib/sv_web/router.ex
pipeline :browser do
# ...
plug SV.Plugs.RequireUser
end
This is stupid, not safe, and you shouldn’t ever use it in a production application (good enough for this guide with sandbox though). Use proper authentication in your real apps.
Pay up
These are the two payment mechanics in Stripe available by default: the simple credit card charge, and the bank-to-bank money transfer that uses the Automated Clearing House Network (ACH) for processing. While credit card payments are very global (thanks to Visa and Mastercard), ACH in Stripe is only available to US-based businesses. Among many other payment methods, a great European alternative that is worth looking into is the Single Euro Payments Area (SEPA) Direct Debit. It’s free by itself, offers a lot of flexibility with reusable authorization to debit the account, and is also available in other payment processing services. But it has its own quirks - for example, a customer is entitled to a full refund on any dispute raised on a less than 2 month old SEPA payment.
Keeping it simple, we’ll use prebuilt Stripe’s Checkout Session. It generates a special link for the customer, with details of the purchase they’re about to make. From then on, the whole process will be handled by Stripe. At this point you’ll also need to set your sandbox company’s name on the dashboard page (top left corner).
Add a new function to the context (there’s quite a lot of configurable stuff in the Checkout Session):
# lib/sv/stripe.ex
def create_session(base_url, price_id, mode, shippable, customer_email, customer_id) do
attrs = %{
cancel_url: "#{base_url}",
success_url: "#{base_url}success",
customer_email: customer_email,
customer: customer_id,
"payment_method_types[0]": "card",
mode: mode,
"line_items[0][price]": price_id,
"line_items[0][quantity]": 1
}
# You can only specify either customer or customer_email, not both
attrs = if customer_id, do: Map.delete(attrs, :customer_email), else: Map.delete(attrs, :customer)
# Add shipping address inputs to the checkout if the product is shippable
attrs =
if shippable == "true" do
Map.merge(attrs, %{"shipping_address_collection[allowed_countries][0]": "US"})
else
attrs
end
Stripy.req(:post, "checkout/sessions", attrs)
|> decode
end
add a new ProductController
action create
, that will create a checkout session and redirect the customer to its URL, plus a success
action - the page to return to after a successful purchase:
# lib/sv/stripe.ex
def create(conn, %{"p" => %{"price_id" => price_id, "mode" => mode, "shippable" => shippable}}) do
base_url = Routes.product_url(conn, :index)
customer_email = conn.assigns[:user_email]
customer_id = nil
{:ok, %{"url" => url}} = SV.Stripe.create_session(base_url, price_id, mode, shippable, customer_email, customer_id)
redirect(conn, external: url)
end
def success(conn, _params) do
render(conn, "success.html")
end
Add purchase forms to prices’ list items in the product’s template lib/sv_web/templates/product/show.html.heex
:
<li>
<!-- ... -->
<%= form_for :p, Routes.product_path(@conn, :create), fn f -> %>
<%= hidden_input f, :price_id, value: price["id"] %>
<%= hidden_input f, :mode, value: (if price["recurring"], do: "subscription", else: "payment") %>
<%= hidden_input f, :shippable, value: @product["shippable"] %>
<%= submit "Purchase now" %>
<% end %>
</li>
New success page - lib/sv_web/templates/product/success.html.heex
:
<h1>Thank you for your order</h1>
<p>If you have any questions, contact us at <a href="mailto:purchases@example.com">purchases@example.com</a></p>
<span><%= link "Back to products", to: Routes.product_path(@conn, :index) %></span>
Head on to a product’s page, and click a “Purchase now” button - you’ll be redirected to Stripe’s checkout page. Use the famous testing credit card number “4242 4242 4242 4242” (any future expiration month, any CVC).
If you try purchasing a shippable product (Landfill 1 tonne
), you’ll see there are a bunch of input fields for the shipping address, along with validations, autosuggestion, and saving the data automatically. Imagine how much time you’d need to implement all of that by yourself!
Lastly, we currently don’t pass the customer_id
, so every checkout payment would result in a new customer being created. That still works, but it’s better if we keep one customer per one email. To do so, first add a function that finds customer’s id by email in the Stripe context:
# lib/sv/stripe.ex
def get_customer_id_by_email(email) do
Stripy.req(:get, "customers", %{email: email})
|> decode()
|> case do
{:ok, %{"data" => [%{"id" => id} | _]}} -> id
_ -> nil
end
end
Then set it in the product#create
controller action:
# ...
customer_id = Stripe.get_customer_id_by_email(customer_email)
Now the Checkout Session uses the customer_id
we provided, so customer’s multiple purchases will be correctly assigned to just one customer account (more on this later).
You can customize the checkout page fairly well, but if you prefer checkout and payment to be done without leaving your website, or just want to have a larger degree of control over the UI, the thing you’re looking for is Stripe Elements. It’s worth noting Stripe will not allow you to collect the critical payment details yourself (like the credit card number) - these input elements are always wrapped in an iframe and hosted by Stripe, as a measure against scams and identity theft.
The admin section
…is already done. Just visit your Stripe dashboard, and see all the financial ruckus your test purchases caused automatically:
- • payments
- • subscriptions
- • new customers
- • invoices
- • reports
- • charts (from my advanced economics knowledge I believe a chart with upwards trend 📈 means “good” (in every case))
If you really need to customize stuff beyond what was already built over years upon years of Stripe development - you can easily fetch the data and implement it yourself - just search for the resource you need in the API docs.
Customer’s history
Obviously we won’t allow customers to access the Stripe dashboard themselves, and currently they have no way of knowing what they are already subscribed to. Let’s amend that quickly. This function will list existing customer’s subscriptions:
# lib/sv/stripe.ex
def get_subscriptions_for_customer(nil), do: {:ok, []}
def get_subscriptions_for_customer(customer_id) do
Stripy.req(:get, "subscriptions", %{customer: customer_id})
|> decode()
end
Subscription status can be 9 different values. We’re not specifying the status filter, so all subscriptions that are not canceled will be returned - exactly what we want here.
Assign customer’s subscribed products in the controller so we know what purchase buttons to skip:
# lib/sv_web/controllers/product_controller.ex
def show(conn, %{"id" => id}) do
# ...
customer_id = Stripe.get_customer_id_by_email(conn.assigns[:user_email])
{:ok, %{"data" => subscriptions}} = Stripe.get_subscriptions_for_customer(customer_id)
subscribed_ids = Enum.map(subscriptions, &(&1["plan"]["product"]))
render(conn, "show.html", product: product, prices: prices, subscribed_ids: subscribed_ids)
end
And change the template lib/sv_web/templates/product/show.html.heex
:
<%= if @product["id"] in @subscribed_ids && price["recurring"] do %>
<div><button>Already subscribed</button></div>
<% else %>
<%= form_for :p, Routes.product_path(@conn, :create), fn f -> %>
<!-- ... -->
<%= submit "Purchase now" %>
<% end %>
<% end %>
Now the user won’t subscribe twice to the same service by accident, but still can make one-time purchases for the product.
There’s much more a proper online store would need: a history of past payments and orders, canceling subscriptions, upgrading/downgrading subscriptions, invoices, metered usage, allowing returns and refunds. How those work exactly are largely a business decision, but if the logic is reasonable, the workflow is most likely already built-in in Stripe, and the implementation side is derivative of what we’ve already done, plus some more time spent searching the API documentation.
Going live
Activate your testing account, toggle the “test mode” switch to live mode, enter your new live API credentials.
The first step is the only one not trivial, and it’s usually not fully automated either, but similar for most payment processors:
They’ll ask for your business details (whatever the local government requires for e-commerce and taxation), check your personal information, and verify your bank account. Remember, that the owner of the account should be the business’ owner, while you’re probably just developing an app. Then you should ask for administrator access in Stripe, which is usually all you need for your programming needs. Some occasional actions, like enabling additional payment methods, may require some more input from the business owner.
Post by Konrad Piekutowski
Konrad has been working with AmberBit since beginning of 2016, but his experience and expertise involves Ruby, Elixir and JavaScript.