Photo

Hi, I'm Aaron.

Integration with Patreon subscription tier via OAuth

Overview

I have a side-gig doing some tech stuff for a TTRPG. This TTRPG is backed partly by Patreon subscriptions. Patreon is a service that allows content creators to receive regular monthly “subscriptions” from their fans. It provides an API (GraphQL) that allows a consumer to receive information about the platform as well as functioning as an OAuth provider.

We intended to allow users to sign-in via Patreon, use the membership information of their Patreon account to gatekeep access to certain features (persisting data to the database, early access to content and new features, etc.)

This article details the specific platform-agnostic process for how to retrieve a dataset that will allow you to verify membership for a patreon tier, as well as discussion about how the data nodes fit together.

The last section provides some sample Ruby classes that I wrote that demonstrate one approach to implementing a response handler for this API response.

Part I: the data nodes

Definitions

member

A “member” is a Patreon user. In this context, it is a Patreon user who has decided to subscribe, for any amount of money including nothing at all, to a content creator.

campaign

A “campaign” is the other end of the membership: it equates to the content creator. I don’t know if it’s possible for a content creator to have multiple campaigns or not, but for this article it doesn’t really matter.

tier

A “tier” is the level of the membership. It has general traits (a name, a subscription fee, etc). It does not represent the individual user memberships, though.

membership

This is the record that connect “member” to “tier”. This has almost all of the data from the tier (and more) but does not have descriptive data like the tier name.

Raw response data

This has been sanitized and also truncated to only show the relevant data nodes we care about when parsing user identity data. Note that the included node has three objects under it: “member”, “campaign”, and “tier”. Locations where there would be additional chaff have been noted with { ... }:

@data= {
  "data" => {
    "attributes" => {
      "email" => "alice@babbage.com", 
      "first_name"=>"Alice", 
      "is_email_verified"=>true, 
      "last_name"=>"Babbage", 
      "vanity"=>"alice_babbage"
    }, 
    "id"=>"1234567", 
    "relationships" => {
      "memberships"=> {
        "data"=> [
          {
            "id"=>"ab1c23de-f45a-6b78-90c1-2d3ef4567890", 
            "type"=>"member"
          }
        ]
      }
    }, 
    "type"=>"user"
  }, 
  "included"=> [
    { ... }
    {
      "attributes"=>{
        "pledge_url"=>"/join/stillfleet", 
        "summary"=>"<< html description >>", 
        "url"=>"https://www.patreon.com/stillfleet", 
        "vanity"=>"stillfleet"
      }, 
      "id"=>"1234567",
      "type"=>"campaign"
    }, {
      ...
    }, { # Stillfleet "member" node
      "attributes" => {
        "campaign_lifetime_support_cents" => 69420,
        "currently_entitled_amount_cents" => 900,
        "is_follower" => false,
        "last_charge_date" => "2023-07-01T12:00:00.000+00:00",
        "last_charge_status" => "Paid",
        "lifetime_support_cents" => 69420,
        "next_charge_date" => "2023-08-01T12:00:00.000+00:00",
        "patron_status" => "active_patron",
        "pledge_cadence" => 1,
        "pledge_relationship_start" => "2020-01-01T00:00:00.000+00:00",
        "will_pay_amount_cents" => 900
      },
      "id" => "ab1c23de-f45a-6b78-90c1-2d3ef4567890",
      "relationships" => {
        "campaign" => {
          "data" => {
            "id" => "1234567", # Stillfleet campaign ID
            "type" => "campaign"
          },
          "links" => {
            "related" => "https://www.patreon.com/api/oauth2/v2/campaigns/1234567"
          }
        },
        "currently_entitled_tiers" => {
          "data" => [
            {
              "id" => "3456789", # Stillfleet Tier ID (Sr. Archivist)
              "type" => "tier"
            }
          ]
        }
      },
      "type" => "member"
    }, {
      ...
    }, {
      "attributes"=>{
        "amount_cents"=>900, 
        "published"=>true, 
        "remaining"=>nil, 
        "title"=>"Senior Archivist", 
        "unpublished_at"=>nil, 
        "url"=>"/join/stillfleet/checkout?rid=3456789", 
        "user_limit"=>nil
      }, 
      "id"=>"3456789", 
      "type"=>"tier"
    },
    { ... }
  ], 
  "links"=>{
    "self"=>"https://www.patreon.com/api/oauth2/v2/user/1234567" # from above
  }
}

Part 2: Parsing the response data

This object is a wrapper that does some data preparation after consuming the raw patreon response data. The source is a graphQL query.

This is the shape of a sample Patreon::Response object that has parsed raw response_data.

Summary of Important values

Type Location inside includes[] Meaning
campaign { node['id'] The campaign ID for this campaign (1234567)
member { node['relationships']['currently_entitled_tiers'][*]['id'] The Stillfleet campaign ID
member node['attributes']['patron_status'] whether or not they are an active_patron
tier node['attributes']['title'] Title of their tier

Overview of response parsing

Patreon uses a graphQL API. We query against the “identity” endpoint, and have it include the “member”, “campaign” and “tier” data. The main “data” node includes identifying data about the user themselves. The “includes” data node includes all of the Stillfleet data.

The unique identifier of the user is the membership UUID:

{
  "data" => {
    "attributes" => { ... },
    "id" => "XXXXXXXX", # This is a red herring!
    "relationships" => {
      "memberships" => {
        "data" => [
          { ... } # There will be others that are similar
          {
            "id" => "ab1c23de-f45a-6b78-90c1-2d3ef4567890", # UUID
            "type" => "member"
          }
          { ... }
        ]
      }
    },
    type => "user"
  }
  "included" => [ ... ]
}

This UUID is then reused in the “member” node.

⚠️ N.B. As there is no identifying information within this node to indicate which campaign is associated with this, you have to look to the included data first. A single user response will very likely have many nodes under "included", "campaign", "tier" and "member" nodes among them, from various campaigns the user may be a part of.

To find which UUID we need, we have to work backwards.

Starting with the "campaign" nodes:

{
  "data" => { ... },
  "included" => [
    { ... }
    { # Stillfleet "campaign" node
      "attributes" => {
        # ...
        "vanity" => "stillfleet"
      },
      "id" => "1234567",
      "type" => "campaign"
    },
    { ... }
  ]
} 

There is one node in included[] where node has: node['type'] == "campaign", and also node['attributes']['vanity'] == "stillfleet". When we find this node, we extract the id attribute: node['id'] and save that away and never forget it.

Stillfleet Campaign ID: 1234567

This id is thankfully universal for all memberships to this campaign, specifically.

With that membership, we can now query for the "member" node with details of the subscription tier.

{
  "data" => { ... },
  "included" => [
    { ... }
    { # Stillfleet "member" node
      "attributes" => {
        # ...
        "patron_status" => "active_patron", # <-- IMPORTANT
        # ...
      },
      "id" => "a159f0de-7689-4b8b-ae89-6b59562589e6", # membership UUID
      "relationships" => {
        "campaign" => {
          "data" => {
            "id" => "1234567", # Stillfleet campaign ID
            "type" => "campaign"
          },
          "links" => { ... }
        },
        "currently_entitled_tiers" => {
          "data" => [
            { ... } # there should not be other tiers here, but it's still a collection
            {
              "id" => "3456789", # Subscription tier ID (Sr. Archivist)
              "type" => "tier"
            }
            { ... }
          ]
        }
      },
      "type" => "member"
    },
    { ... }
  ]
} 

Again, we are looking in a sea of objects that include "member", "tier", and "campaign" nodes, and the only way to tell them apart is by looking into them. This time, we want to look through the includes[] collection for a node that has: node['type'] == "member" and node['relationships']['campaign']['data']['id'] == "1234567".

With that node, we can now look through node['relationships']['currently_entitled_tiers'] and gather all the currently_entitled_tier['id']s. There should only be the one, but good to verify. There is a different tier ID for each subscription level, so this is less globally useful, and should any tiers be changed we would need to know that beforehand, so we don’t store this permanently, only transiently.

Stillfleet subscription tier ID: 3456789

Additionally, while examining this node we should also note the node['attributes']['patron_status'] value – it should be "active_patron" if they are current.

Now we can gather the actual tier level by looking at the "type" => "tier" nodes:

{
  "data" => { ... },
   "included" => [
      { ... }
      { # Stillfleet "tier" node
      "attributes" => {
        "amount_cents" => 900,
        # ...
        "title" => "Senior Archivist",
        # ...
      },
      "id" => "3456789",
      "type" => "tier"
    },
    { ... }
  ]
} 

This time we are looking for a node that has "type" => "tier" and also has node['id'] == "3456789" (the subscription tier ID from earlier). From this we can extract both the node['attributes']['amount_cents'] (if that matters to us) and also the node['attributes']['title'] which gives us the diegetic name of the tier.

This this data collected, we can now assemble this into a meaningful determination of whether or not the user is (a) a subscribing patron, and (b) actively supporting.

Part 3: Storing & using the data

The last phase of this is to take the data we are

Persisting in the Patreon::Response parsed object

I created a Response class under a Patreon module to persist the data for future requests.

These are the fields I stored:

@status= :active_patron,

@user_data    = {
  "patreon_id"        =>  "1234567",
  "email"             =>  "alice@babbage.com", # Not all users have emails provided!
  "first_name"        =>  "Alice", 
  "is_email_verified" =>  true, 
  "last_name"         =>  "Babbage", 
  "vanity"            =>  "alice_babbage"
},

@campaign_id  = "1234567",

@pledge_data  = {
  "campaign_lifetime_support_cents" =>  10000, 
  "currently_entitled_amount_cents" =>  900, 
  "is_follower"                     =>  false, 
  "last_charge_date"                =>  "2023-07-01T12:00:00.000+00:00", 
  "last_charge_status"              =>  "Paid", 
  "lifetime_support_cents"          =>  69420, 
  "next_charge_date"                =>  "2023-08-01T12:00:00.000+00:00", 
  "patron_status"                   =>  "active_patron", 
  "pledge_cadence"                  =>  1, 
  "pledge_relationship_start"       =>  "2020-01-01T12:00:00.000+00:00", 
  "will_pay_amount_cents"           =>  900
},

@subscription_tier = {
  "amount_cents"      =>  900, 
  "published"         =>  true, 
  "remaining"         =>  nil, 
  "title"             =>  "Senior Archivist", 
  "unpublished_at"    =>  nil, 
  "url"               =>  "/join/stillfleet/checkout?rid=3456789", 
  "user_limit"        =>  nil
}>

Sample Patreon::Response class definition

Here is the class used that provides the fields above. It’s written in Ruby and is mostly lifted verbatim from what we’re using. There are some small modifications made for readability.

module Patreon
  # These custom exceptions are for slightly better granular
  # feedback on failures. There are many different ways to run
  # into issues during this processing phase, and some of them
  # are handled differently, or with different error messages.

  class Patreon::NotAValidResponseError < StandardError; end
  class Patreon::NotAPatronError < StandardError; end
  class Patreon::NotAnActivePatronError < StandardError; end
  class Patreon::DataParsingError < StandardError; end


  class Response
    attr_reader :data, :campaign_id, :user_data, :pledge_data, :subscription_tier, :status

    # @param data - a raw Patreon API response object
    def initialize(data)
      @status = :unknown
      @user_data = {}
      @pledge_data = {}
      @subscription_tier = {}

      # This will raise a JSON::ParserError if it is not JSON data
      @data = data.is_a?(Hash) ? data : JSON.parse(data)

      # The strategy here is to bail out of execution if we encounter
      # any showstopping errors. Some of these exceptions are handled
      # upstream.
      raise Patreon::NotAValidResponseErrorunless has_user_data?
      find_user

      raise Patreon::NotAPatronError unless has_historical_subscription_data?
      find_pledge_data

      raise Patreon::NotAnActivePatronError unless has_current_subscription_data?
      find_subscription_tier
      
      # This status is set implicitly if we have not encountered any
      # showstopping errors so far.
      @status = :active_patron  
    
    # These statuses cover those other circumstances. In the real
    # code in prod, there is some additional debug dumping happening
    # within these blocks as well, but the only required part is 
    # that the statuses are set correctly.
    rescue Patreon::NotAPatronError => _
      @status = :not_a_patron
    rescue Patreon::NotAnActivePatronError => _
      @status = :not_an_active_patron
    end

    # Technically, this is a known value but we're memoizing it 
    # dynamically as an early smoke test.
    # the approach of "rescue StandardError, re-raise as a more
    # specific error" is a strategy used throughout this class.
    def campaign_id
      @campaign_id ||= campaign['id']
    rescue StandardError => e
      raise Patreon::NotAPatronError, e
    end

    private

    def has_user_data?
      data.dig('data', 'id').present?
    end

    # In these two has_*_data? methods, we're using `rescue false`
    # because both helper methods that are called previously _may_
    # raise exceptions, depending on their behavior or the status of
    # the response. As this is a predicate, we always want to respond
    # with a true or false value.
    def has_historical_subscription_data?
      historical_subscription_data.present?
    rescue
      false
    end

    def has_current_subscription_data?
      current_subscription_data.present?
    rescue
      false
    end

    # The find_* methods all encapsulate the response object
    # traversal. Initially, I considered memoizing these methods
    # instead, but that approach didn't fit the flow above.
    def find_user
      # This might read awkwardly as two steps, here. The reason
      # it's broken up is because there was a step between them
      # that I did for debugging reasons that isn't germane to this
      # article.
      @user_data = { 'patreon_id' => data['data']['id'] }
      @user_data.merge!(data.dig('data','attributes'))
    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end

    def find_pledge_data
      @pledge_data = membership['attributes']
    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end

    # Because of how ruby behaves, the "current" data will be 
    # applied on-top-of (ie. it will overwrite) any colliding
    # keys.
    def find_subscription_tier
      @subscription_tier = historical_subscription_data.merge(current_subscription_data)
    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end

    def historical_subscription_data
      data['included'].find do |node|
        node['type'] == 'member' &&
          node.dig('relationships', 'campaign', 'data', 'id') == campaign_id
      end['attributes']
    end

    def current_subscription_data
      data['included'].find do |node|
        node['type'] == 'tier' &&
          node['id'] == subscription_tier_id
      end['attributes']
    rescue StandardError => e
      {}
    end

    def subscription_tier_id
      @subscription_tier_id ||= membership.dig(
                                  'relationships',
                                  'currently_entitled_tiers',
                                  'data'
                                ).first&.fetch('id')

    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end

    # This method is memoized.
    def membership 
      @membership ||= data['included'].find do |node| 
        node['type'] == 'member' &&
          node.dig('relationships', 'campaign', 'data', 'id') == campaign_id
      end

    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end

    def campaign
      @campaign ||= data['included'].find({}) do |node| 
        node['type'] == 'campaign' &&
          node.dig('attributes', 'vanity')&.downcase == 'stillfleet' 
      end
    rescue StandardError => e
      raise Patreon::DataParsingError, e
    end
  end
end

In OAuth response controller action

I’ll have to write a separate post about using Patreon for OAuth specifically, but here’s the important steps of the controller action that Patreon OAuth webhook hits when the user signs-in via Patreon:

class OAuthController < ApplicationController
  # ...

  def patreon_redirect
    # ... do the OAuth request via an API client
    # these first two steps are included only for context, but 
    # use bespoke code. :fetch_identity returns the JSON response.
    api_client = Patreon::PatreonClient.new(access_token)
    response_data = api_client.fetch_identity

    # this the class from above.
    response = Patreon::Response.new(response_data)
    # Do stuff with the response object -- instantiate objects, etc.

    # IRL, this is a little more complicated. Not every user puts
    # their name in the first/last name. So I actually have a method
    # that checks name, userID, email, etc and uses the best one it
    # finds.
    redirect_to
      root_path,
      notice: "Welcome, #{response.user_data['first_name']}!"
    
    rescue Patreon::DataParsingError, Patreon::NotAPatronError => e
      redirect_to
        root_path, 
        notice: "Welcome, guest! Please consider subscribing to our Patreon!"
    rescue Patreon::NotAnActivePatronError => e
      redirect_to
        root_path, 
        notice: "Welcome back, friend! Please consider subscribing to our Patreon again!"
    rescue StandardError => e
      redirect_to
        root_path,
        notice: "We had trouble signing-in."
    end
    # ...
  end