As part of my recent work on building Mailgrip, I decided to experiment with using Gumroad to manage payments and subscriptions. This post documents the minimum viable Gumroad integration I implemented, in case you are looking at doing the same thing!
Why Gumroad? At a bare minimum I wanted to be able to charge customers an annual subscription fee, offer free trials, and have tax handled for me. Gumroad fit the bill on all these requirements. Its marketplace and hosted landing pages were also a nice bonus.
Two landing pages… It is possible to use a Gumroad landing page as the only landing page for a product, but I wanted more flexibility in layout and portability if I wanted to switch to another provider down the line, so I ended up creating a self-hosted landing page too.
This approach led to needing to support two potential ways a customer can sign up and subscribe to Mailgrip:
- Customer lands on the Mailgrip site, signs up, and then needs to subscribe on Gumroad
- Customer lands on the Gumroad landing page, subscribes, and then needs to sign up on Mailgrip
Even with needing to support both flows, integrating with Gumroad ended up being simpler than similar work I’ve done with payment processors like Stripe. This largely came down to Gumroad’s API being narrower and less flexible than Stripe’s, and when working on a small side project freedom from choice can be a wonderful thing!
Implementation details
This section focuses on the core parts of Mailgrip’s Gumroad integration. Mailgrip is written in Elixir using the Phoenix framework so code samples will be in Elixir - but the underlying approach should be generic enough to be relevant to any stack.
Custom content delivery webhook
The main feature on Gumroad that drove my implementation is custom content delivery. It let me specify a custom URL to redirect customers to after they subscribe to Mailgrip, and invoke arbitrary behaviour within the system after a successful sale.
In Mailgrip, the handler for this webhook checks that the customer already has an account (if they don’t they’re asked to make one), ensures that the sale hasn’t already been claimed by another customer, and finally claims the sale for the logged in customer and marks them as having an active subscription.
Here’s a sequence diagram illustrating the flow:
… and here’s some annotated source from my implementation:
defmodule MailgripWeb.GumroadSaleController do
require Logger
use MailgripWeb, :controller
alias Mailgrip.Subscriptions
alias MailgripWeb.UserAuth
plug(:validate_product_id when action in [:new])
plug(:validate_sale_with_gumroad when action in [:new])
# If there isn't a current user (i.e. customer is not logged in), then
# redirect to the registration flow.
def new(%{assigns: %{current_user: nil, gumroad_sale: gumroad_sale}} = conn, _params) do
%{purchase_email: email} = gumroad_sale
conn
|> UserAuth.require_authenticated_user(
redirect_to: Routes.user_registration_path(conn, :new, via_gumroad: "true", email: email)
)
end
# If there is a current user (i.e. customer IS logged in), then
# claim the sale on their behalf...
def new(
%{assigns: %{current_user: %{id: current_user_id}, gumroad_sale: %{id: sale_id}}} = conn,
_params
) do
case Subscriptions.get_sale(sale_id) do
# Only create the subscription and claim the sale for the user
# if the sale hasn't been claimed already
nil ->
conn |> create_subscription()
# Otherwise check if this user has claimed the sale before, and
# if they have redirect them to the main page.
%{user_id: ^current_user_id} ->
conn |> redirect(Routes.inbox_path(conn, :index))
# If another user has claimed the sale, show an error and redirect.
_sale ->
Logger.warn("Tried to activate a sale that's already been activated",
sale_id: sale_id,
user_id: current_user_id
)
conn
|> put_flash(:error, """
This Gumroad purchase has already been claimed.
Please contact us to get this resolved.
""")
|> redirect(to: "/")
end
end
defp create_subscription(%{assigns: %{current_user: user, gumroad_sale: sale}} = conn) do
# ...
# Omitted the internals of this function for brevity. It includes
# logic for marking the currently logged in user as
# "subscribed", and marking the Gumroad sale as claimed by that user.
end
# Validates the sale against the live Gumroad API, and assigns it to
# the request's state so it can be used later.
defp validate_sale_with_gumroad(%{params: %{"sale_id" => sale_id}} = conn, _opts) do
case Gumroad.get_sale(sale_id) do
{:ok, sale} ->
conn |> assign(:gumroad_sale, sale)
_ ->
Logger.warn("Tried to activate a sale that can't be found on Gumroad", sale_id: sale_id)
conn
|> put_flash(:error, """
Your Gumroad purchase could not be activated.
Try clicking on the link to open Mailgrip in Gumroad again, and failing
that, please contact us to get this resolved.
""")
|> redirect(to: "/")
|> halt()
end
end
# Validates that the product sent by Gumroad matches up with the product
# we're expecting to receive events for.
defp validate_product_id(%{params: %{"product_id" => product_id}} = conn, _opts) do
if product_id != Application.fetch_env!(:mailgrip, :gumroad_product_id) do
Logger.warn("Gumroad sent a request for an invalid product", product_id: product_id)
conn
|> put_flash(
:error,
"""
We can't find your Gumroad purchase in our system.
Please contact us to get this resolved.
"""
)
|> redirect(to: "/")
else
conn
end
end
end
Redirect to Gumroad checkout page
When a customer signs up for from the Mailgrip site, but then needs to finalise their subscription on Gumroad, they’re presented with this screen:
On clicking the “Create subscription” button, they’re taken straight to the Gumroad checkout page with their email address already filled in:
To get this deep-link straight to the checkout page, I add the wanted
and email
params to my product’s URL:
https://alexpls.gumroad.com/l/mailgrip?wanted=true&[email protected]
Here’s the view helper I use to generate that URL:
defmodule MailgripWeb.SubscriptionView do
def gumroad_checkout_url(email) do
"https://alexpls.gumroad.com/l/mailgrip"
|> URI.parse()
|> Map.put(
:query,
URI.encode_query(%{
# Prefills the user's email
"email" => email,
# Setting "wanted" takes the user straight to the checkout
# page.
"wanted" => true
})
)
|> URI.to_string()
end
end
Handling subscription state changes
When a customer’s subscription changes in Gumroad (i.e. extension or cancellation), Mailgrip needs to know about it so it can update the customer’s access.
To handle this I create a Gumroad resource_subscription
. I found the API naming here to be somewhat confusing, because a Resource Subscription has nothing to do with subscriptions to a product, but rather subscriptions to a resource’s events (e.g. sales, subscription updates, etc).
So essentially, creating a resource_subscription
registers a webhook on Mailgrip that Gumroad will make a request against whenever an event you’re interested in happens.
On Mailgrip’s app startup, I start a one-off Task
that ensures any events I want to subscribe to (cancellation
, subscription_updated
, subscription_ended
, subscription_restarted
) have an active subscription, and if they don’t - create one! Here’s the code:
defmodule MailgripWeb.GumroadWebhookSubscriber do
use Task
require Logger
def start_link(arg) do
Task.start_link(__MODULE__, :run, [arg])
end
def run() do
Logger.info("Setting up Gumroad webhook subscriptions")
wanted_subscriptions()
|> Enum.each(&maybe_create_sub/1)
Logger.info("Gumroad webhooks set up")
end
defp wanted_subscriptions do
events = ~w(cancellation subscription_updated subscription_ended subscription_restarted)
Enum.map(events, fn e -> %{"resource_name" => e, "post_url" => webhook_url()} end)
end
defp webhook_url do
MailgripWeb.Router.Helpers.gumroad_webhook_url(
MailgripWeb.Endpoint,
:handle
)
end
defp maybe_create_sub(%{"resource_name" => resource_name, "post_url" => post_url} = params) do
{:ok, existing} = Gumroad.get_resource_subscriptions(resource_name)
unless Enum.any?(existing, fn rs -> rs.post_url == post_url end) do
Logger.info("Creating webhook subscription", params: params)
Gumroad.create_resource_subscription(params)
end
end
end
As far as handling those events goes, the implementation looks like:
defmodule MailgripWeb.GumroadWebhookController do
require Logger
use MailgripWeb, :controller
alias Mailgrip.Accounts
def handle(conn, %{"subscription_id" => subscription_id, "user_id" => user_id}) do
{:ok, sub} = Gumroad.get_subscriber(subscription_id)
{:ok, _} =
Accounts.get_user_by_gumroad_id!(user_id)
|> Accounts.update_user_gumroad_details(%{
gumroad_subscription_status: sub.status
})
conn |> json(%{"status" => "ok"})
end
end
Bringing it together
Here are a couple of videos showing the flow in action. I am happy with the customer experience - all up it should take about 30 seconds for a customer to sign up and subscribe (longer if they don’t already have credit card details on Gumroad).
(There’re HTTPS security warnings because this is running a dev build).
Customer signs up via Mailgrip, subscribes via Gumroad
Customer subscribes via Gumroad, signs up via Mailgrip
Gumroad gotchas
All up I found it pretty straightforward to get started with Gumroad, but there are still some things that surprised me about the experience. I admit there’s a heavy bias in this section around where Gumroad differs from Stripe, because Stripe’s the tool I know best!
Gumroad’s API docs are lacking
While there’s enough info on Gumroad’s API site to get started with building something, it doesn’t offer enough detail to be confident in an integration without extensive testing.
- The example responses on the API docs don’t include much information about each field returned, its type, if it’s optional, etc. This leads to having to be quite defensive when building an integration.
- Some resources are named inconsistently. i.e. “subscription” and “membership” seem to be used interchangeably. Webhooks are referred to as “Pings” in places, but “resource subscriptions” in others.
Having a first party OpenAPI spec published by Gumroad would go a very long way towards making this better.
There isn’t a test environment
Gumroad doesn’t have a test environment you can use as a sandbox while you’re developing your application.
This means you have to get creative with how you test things out. For me this included creating a separate product that I could use just for my development tests, and writing a Gumroad client that included built-in mocks so non-prod environments wouldn’t need to go out to the Gumroad API at all.
This also leads Gumroad to making some choices around their APIs that are a bit unintuitive. For example when you purchase your own product for testing purposes, the sale that gets generated as part of that doesn’t get returned when listing sales for your product, however it does show up if you know the sale’s ID and query for it directly. This kind of inconsistency leads to some confusion during development.
High-end pricing
Gumroad’s recently announced a significant increase to their fees. If you’re looking at integrating with them, make sure you understand the pricing first and look into competitors beforehand!
Conclusion
That covers the interesting parts of my recent integration with Gumroad. I hope this post will make your time setting up SaaS on Gumroad easier. If you’re interested in more specific details about the Mailgrip integration, reach out!