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