Selling SaaS on Gumroad

| Permalink

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:

  1. Customer lands on the Mailgrip site, signs up, and then needs to subscribe on Gumroad
  2. 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:

CustomerMailgripGumroadMakes request to customcontent delivery URLSubscribesValidates saleWhen sale is invalidDenies accessWhen sale is validA sale can be invalid ifit can't be found on Gumroador if it's already been claimed.Validates usersessionWhen user is notlogged inRedirects to signup flowSigns up or logs inRedirects back to custom content delivery URLThe initial content delivery URLreceived from Gumroad is stored onthe session, so when the usersigns up/signs in they can be redirectedback to it to complete the flow.When user islogged inClaims saleand activatessubscriptionAccess granted!

… 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:

a screenshot of the screen that redirects customers to Gumroad

On clicking the “Create subscription” button, they’re taken straight to the Gumroad checkout page with their email address already filled in:

a screenshot of the Gumroad checkout page

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!