惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

F
Full Disclosure
WordPress大学
WordPress大学
小众软件
小众软件
Cloudbric
Cloudbric
AWS News Blog
AWS News Blog
腾讯CDC
量子位
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
V
Vulnerabilities – Threatpost
Scott Helme
Scott Helme
Hugging Face - Blog
Hugging Face - Blog
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
IT之家
IT之家
Jina AI
Jina AI
Attack and Defense Labs
Attack and Defense Labs
S
SegmentFault 最新的问题
Simon Willison's Weblog
Simon Willison's Weblog
The Cloudflare Blog
阮一峰的网络日志
阮一峰的网络日志
T
Tailwind CSS Blog
Last Week in AI
Last Week in AI
博客园 - 【当耐特】
Google Online Security Blog
Google Online Security Blog
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
Visual Studio Blog
罗磊的独立博客
L
LINUX DO - 最新话题
博客园 - Franky
博客园 - 叶小钗
Apple Machine Learning Research
Apple Machine Learning Research
The Last Watchdog
The Last Watchdog
J
Java Code Geeks
AI
AI
C
Cisco Blogs
酷 壳 – CoolShell
酷 壳 – CoolShell
C
Cyber Attacks, Cyber Crime and Cyber Security
Cisco Talos Blog
Cisco Talos Blog
博客园 - 三生石上(FineUI控件)
雷峰网
雷峰网
Help Net Security
Help Net Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
云风的 BLOG
云风的 BLOG
I
Intezer
S
Securelist

Home on Alex Plescan

Just for fun: animating a mosaic of 90s GIFs Two computers, one monitor, zero fiddling Placeholder names should be bad and unique Rebuilding this site Okay, I really like WezTerm GNU Parallel, where have you been all my life? Timeseries with PostgreSQL Easy SVG sparklines Using Declarative Shadow DOM to embed HTML emails on a web page PDF: The Conjoined Triangles of Success Deploying Metabase to Fly.io The ".x" Files Xcode 8 managed signing: adding new device UUIDs to a provisioning profile Emojify your Wi-Fi (Netgear R6300 edition) How to use the San Francisco Mono typeface before macOS Sierra is released Disabling App Transport Security in your development environment Swift: A nicer way to tell if your app is running in Debug mode Development environment config overrides in Jekyll Setting up SwiftLint on Travis CI
Selling SaaS on Gumroad
2022-12-22 · via Home on Alex Plescan

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!