Discord reward roles never removed when payment is declined

When Discord roles are enabled as rewards, the users can submit a pledge on your Patreon, then subsequently fail to pay (remove money from their payment method, don’t allow the payment to be processed, …) and they will keep their Discord roles.

Is there anything we can do to stop this abuse?

The only route I’m looking at so far is to query Patreon’s API to get a list of all failed payments, retrieving the Discord ID from the API and removing them myself with a custom bot.

Edit: I wrote the bot to detect the users with Discord roles and declined payments, there are several. And I’ve found at least one user who doesn’t have an active pledge, yet still has his Discord role. What?

I hate that I have to give this answer, but yes, you are totally correct that at the moment, pledge declines don’t remove patrons from Discord properly. It is something that we are aware of, and I’m pushing for it to get done as soon as possible.

If you PM me your user id, I can look into the users without pledges who have Discord roles.

I’m having the same problem. Declined payments aren’t getting the user removed from my discord server.

Hi, I’m having this exact same problem. Is there any news on this?

The issue is webhooks the bot probably does not use it all or checking for the declines properly. However without a clear easy way on the “campaign” to return that status when it shows the members and whatnot instead of needing to request the specific information via the api it might be faster and easier (at least with the non-deprecated hooks). Although the deprecated ones also sadly only contains your “payment” info if it was declined or not and not of everyone in a campaign… I think that information is needed tbh.

Now if I had a person with a published campaign that has patrons if they can provide a filtered json that I can generate code on the webhooks that is more accurate (filling in all possible fields in) and nothing being null I could make my api that allows use of webhooks to patreon to be more perfected. Man there needs to be a place in the api where it would return a version of the webhook info for both deprecated and non-deprecated webhook requests filling in all possible information even the “patron” entries of it even if they are not valid users. Currently:

// <auto-generated />
//
// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do:
//
//    using WebApi1.Patreon;
//
//    var patreonRequestData = PatreonRequestData.FromJson(jsonString);
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace WebApi1.Patreon
{
    public partial class PatreonRequestData
    {
        [JsonProperty("data")]
        public Data Data { get; set; }

        [JsonProperty("included")]
        public Included[] Included { get; set; }

        [JsonProperty("links")]
        public PatreonRequestDataLinks Links { get; set; }
    }

    public partial class Data
    {
        [JsonProperty("attributes")]
        public DataAttributes Attributes { get; set; }

        [JsonProperty("id")]
        public object Id { get; set; }

        [JsonProperty("relationships")]
        public DataRelationships Relationships { get; set; }

        [JsonProperty("type")]
        public string Type { get; set; }
    }

    public partial class DataAttributes
    {
        [JsonProperty("full_name")]
        public string FullName { get; set; }

        [JsonProperty("is_follower")]
        public bool IsFollower { get; set; }

        [JsonProperty("last_charge_date")]
        public DateTimeOffset LastChargeDate { get; set; }

        [JsonProperty("last_charge_status")]
        public string LastChargeStatus { get; set; }

        [JsonProperty("lifetime_support_cents")]
        public long LifetimeSupportCents { get; set; }

        [JsonProperty("patron_status")]
        public string PatronStatus { get; set; }

        [JsonProperty("pledge_amount_cents")]
        public long PledgeAmountCents { get; set; }

        [JsonProperty("pledge_cap_amount_cents")]
        public object PledgeCapAmountCents { get; set; }

        [JsonProperty("pledge_relationship_start")]
        public DateTimeOffset PledgeRelationshipStart { get; set; }
    }

    public partial class DataRelationships
    {
        [JsonProperty("address")]
        public Address Address { get; set; }

        [JsonProperty("campaign")]
        public Campaign Campaign { get; set; }

        [JsonProperty("user")]
        public Campaign User { get; set; }
    }

    public partial class Address
    {
        [JsonProperty("data")]
        public Dat[] Data { get; set; }
    }

    public partial class Dat
    {
        // [JsonConverter(typeof(ParseStringConverter))]
        [JsonProperty("id")]
        public string Id { get; set; }

        [JsonProperty("type")]
        public string Type { get; set; }
    }

    public partial class Campaign
    {
        [JsonProperty("data")]
        public Dat Data { get; set; }

        [JsonProperty("links")]
        public CampaignLinks Links { get; set; }
    }

    public partial class CampaignLinks
    {
        [JsonProperty("related")]
        public Uri Related { get; set; }
    }

    public partial class Included
    {
        [JsonProperty("attributes")]
        public IncludedAttributes Attributes { get; set; }

        // [JsonConverter(typeof(ParseStringConverter))]
        [JsonProperty("id")]
        public string Id { get; set; }

        [JsonProperty("relationships", NullValueHandling = NullValueHandling.Ignore)]
        public IncludedRelationships Relationships { get; set; }

        [JsonProperty("type")]
        public string Type { get; set; }
    }

    public partial class IncludedAttributes
    {
        [JsonProperty("avatar_photo_url", NullValueHandling = NullValueHandling.Ignore)]
        public Uri AvatarPhotoUrl { get; set; }

        [JsonProperty("cover_photo_url")]
        public object CoverPhotoUrl { get; set; }

        [JsonProperty("created_at")]
        public DateTimeOffset? CreatedAt { get; set; }

        [JsonProperty("creation_count", NullValueHandling = NullValueHandling.Ignore)]
        public long? CreationCount { get; set; }

        [JsonProperty("creation_name", NullValueHandling = NullValueHandling.Ignore)]
        public string CreationName { get; set; }

        [JsonProperty("currency", NullValueHandling = NullValueHandling.Ignore)]
        public string Currency { get; set; }

        [JsonProperty("discord_server_id")]
        public object DiscordServerId { get; set; }

        [JsonProperty("display_patron_goals", NullValueHandling = NullValueHandling.Ignore)]
        public bool? DisplayPatronGoals { get; set; }

        [JsonProperty("earnings_visibility", NullValueHandling = NullValueHandling.Ignore)]
        public string EarningsVisibility { get; set; }

        [JsonProperty("image_small_url")]
        public object ImageSmallUrl { get; set; }

        [JsonProperty("image_url")]
        public Uri ImageUrl { get; set; }

        [JsonProperty("is_charge_upfront", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsChargeUpfront { get; set; }

        [JsonProperty("is_charged_immediately", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsChargedImmediately { get; set; }

        [JsonProperty("is_monthly", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsMonthly { get; set; }

        [JsonProperty("is_nsfw", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsNsfw { get; set; }

        [JsonProperty("is_plural", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsPlural { get; set; }

        [JsonProperty("main_video_embed")]
        public object MainVideoEmbed { get; set; }

        [JsonProperty("main_video_url")]
        public object MainVideoUrl { get; set; }

        [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
        public string Name { get; set; }

        [JsonProperty("one_liner")]
        public object OneLiner { get; set; }

        [JsonProperty("outstanding_payment_amount_cents", NullValueHandling = NullValueHandling.Ignore)]
        public long? OutstandingPaymentAmountCents { get; set; }

        [JsonProperty("patron_count", NullValueHandling = NullValueHandling.Ignore)]
        public long? PatronCount { get; set; }

        [JsonProperty("pay_per_name", NullValueHandling = NullValueHandling.Ignore)]
        public string PayPerName { get; set; }

        [JsonProperty("pledge_sum", NullValueHandling = NullValueHandling.Ignore)]
        public long? PledgeSum { get; set; }

        [JsonProperty("pledge_url", NullValueHandling = NullValueHandling.Ignore)]
        public string PledgeUrl { get; set; }

        [JsonProperty("published_at")]
        public DateTimeOffset? PublishedAt { get; set; }

        [JsonProperty("summary", NullValueHandling = NullValueHandling.Ignore)]
        public string Summary { get; set; }

        [JsonProperty("thanks_embed", NullValueHandling = NullValueHandling.Ignore)]
        public string ThanksEmbed { get; set; }

        [JsonProperty("thanks_msg")]
        public object ThanksMsg { get; set; }

        [JsonProperty("thanks_video_url")]
        public object ThanksVideoUrl { get; set; }

        [JsonProperty("url")]
        public string Url { get; set; }

        [JsonProperty("about", NullValueHandling = NullValueHandling.Ignore)]
        public string About { get; set; }

        [JsonProperty("can_see_nsfw", NullValueHandling = NullValueHandling.Ignore)]
        public bool? CanSeeNsfw { get; set; }

        [JsonProperty("created", NullValueHandling = NullValueHandling.Ignore)]
        public DateTimeOffset? Created { get; set; }

        [JsonProperty("default_country_code")]
        public object DefaultCountryCode { get; set; }

        [JsonProperty("discord_id", NullValueHandling = NullValueHandling.Ignore)]
        public string DiscordId { get; set; }

        [JsonProperty("email", NullValueHandling = NullValueHandling.Ignore)]
        public string Email { get; set; }

        [JsonProperty("facebook")]
        public object Facebook { get; set; }

        [JsonProperty("facebook_id")]
        public object FacebookId { get; set; }

        [JsonProperty("first_name", NullValueHandling = NullValueHandling.Ignore)]
        public string FirstName { get; set; }

        [JsonProperty("full_name", NullValueHandling = NullValueHandling.Ignore)]
        public string FullName { get; set; }

        [JsonProperty("gender", NullValueHandling = NullValueHandling.Ignore)]
        public long? Gender { get; set; }

        [JsonProperty("google_id")]
        public object GoogleId { get; set; }

        [JsonProperty("has_password", NullValueHandling = NullValueHandling.Ignore)]
        public bool? HasPassword { get; set; }

        [JsonProperty("is_deleted", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsDeleted { get; set; }

        [JsonProperty("is_email_verified", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsEmailVerified { get; set; }

        [JsonProperty("is_nuked", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsNuked { get; set; }

        [JsonProperty("is_suspended", NullValueHandling = NullValueHandling.Ignore)]
        public bool? IsSuspended { get; set; }

        [JsonProperty("last_name", NullValueHandling = NullValueHandling.Ignore)]
        public string LastName { get; set; }

        [JsonProperty("social_connections", NullValueHandling = NullValueHandling.Ignore)]
        public SocialConnections SocialConnections { get; set; }

        [JsonProperty("thumb_url", NullValueHandling = NullValueHandling.Ignore)]
        public Uri ThumbUrl { get; set; }

        [JsonProperty("twitch")]
        public object Twitch { get; set; }

        [JsonProperty("twitter")]
        public object Twitter { get; set; }

        [JsonProperty("vanity")]
        public object Vanity { get; set; }

        [JsonProperty("youtube", NullValueHandling = NullValueHandling.Ignore)]
        public Uri Youtube { get; set; }

        [JsonProperty("amount", NullValueHandling = NullValueHandling.Ignore)]
        public long? Amount { get; set; }

        [JsonProperty("amount_cents", NullValueHandling = NullValueHandling.Ignore)]
        public long? AmountCents { get; set; }

        [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)]
        public string Description { get; set; }

        [JsonProperty("remaining")]
        public long? Remaining { get; set; }

        [JsonProperty("requires_shipping", NullValueHandling = NullValueHandling.Ignore)]
        public bool? RequiresShipping { get; set; }

        [JsonProperty("user_limit")]
        public object UserLimit { get; set; }

        [JsonProperty("discord_role_ids")]
        public object DiscordRoleIds { get; set; }

        [JsonProperty("edited_at")]
        public object EditedAt { get; set; }

        [JsonProperty("post_count", NullValueHandling = NullValueHandling.Ignore)]
        public long? PostCount { get; set; }

        [JsonProperty("published", NullValueHandling = NullValueHandling.Ignore)]
        public bool? Published { get; set; }

        [JsonProperty("title")]
        public string Title { get; set; }

        [JsonProperty("unpublished_at")]
        public object UnpublishedAt { get; set; }

        [JsonProperty("welcome_message")]
        public object WelcomeMessage { get; set; }

        [JsonProperty("welcome_message_unsafe")]
        public object WelcomeMessageUnsafe { get; set; }

        [JsonProperty("welcome_video_embed")]
        public object WelcomeVideoEmbed { get; set; }

        [JsonProperty("welcome_video_url")]
        public object WelcomeVideoUrl { get; set; }

        [JsonProperty("completed_percentage", NullValueHandling = NullValueHandling.Ignore)]
        public long? CompletedPercentage { get; set; }

        [JsonProperty("reached_at")]
        public object ReachedAt { get; set; }
    }

    public partial class SocialConnections
    {
        [JsonProperty("deviantart")]
        public object Deviantart { get; set; }

        [JsonProperty("discord")]
        public Discord Discord { get; set; }

        [JsonProperty("facebook")]
        public object Facebook { get; set; }

        [JsonProperty("google")]
        public object Google { get; set; }

        [JsonProperty("instagram")]
        public object Instagram { get; set; }

        [JsonProperty("reddit")]
        public object Reddit { get; set; }

        [JsonProperty("spotify")]
        public object Spotify { get; set; }

        [JsonProperty("twitch")]
        public object Twitch { get; set; }

        [JsonProperty("twitter")]
        public object Twitter { get; set; }

        [JsonProperty("youtube")]
        public object Youtube { get; set; }
    }

    public partial class Discord
    {
        [JsonProperty("scopes")]
        public string[] Scopes { get; set; }

        [JsonProperty("url")]
        public object Url { get; set; }

        [JsonProperty("user_id")]
        public string UserId { get; set; }
    }

    public partial class IncludedRelationships
    {
        [JsonProperty("creator", NullValueHandling = NullValueHandling.Ignore)]
        public Campaign Creator { get; set; }

        [JsonProperty("goals", NullValueHandling = NullValueHandling.Ignore)]
        public Address Goals { get; set; }

        [JsonProperty("rewards", NullValueHandling = NullValueHandling.Ignore)]
        public Address Rewards { get; set; }

        [JsonProperty("campaign", NullValueHandling = NullValueHandling.Ignore)]
        public Campaign Campaign { get; set; }
    }

    public partial class PatreonRequestDataLinks
    {
        [JsonProperty("self")]
        public Uri Self { get; set; }
    }

    public partial class PatreonRequestData
    {
        public static PatreonRequestData FromJson(string json) => JsonConvert.DeserializeObject<PatreonRequestData>(json, WebApi1.Patreon.Converter.Settings);
    }

    public static class Serialize
    {
        public static string ToJson(this PatreonRequestData self) => JsonConvert.SerializeObject(self, WebApi1.Patreon.Converter.Settings);
    }

    internal static class Converter
    {
        public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
        {
            MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
            DateParseHandling = DateParseHandling.None,
            Converters =
            {
                new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
            },
        };
    }

    internal class ParseStringConverter : JsonConverter
    {
        public override bool CanConvert(Type t) => t == typeof(long) || t == typeof(long?);

        public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
        {
            if (reader.TokenType == JsonToken.Null) return null;
            var value = serializer.Deserialize<string>(reader);
            long l;
            if (Int64.TryParse(value, out l))
            {
                return l;
            }
            throw new Exception("Cannot unmarshal type long");
        }

        public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
        {
            if (untypedValue == null)
            {
                serializer.Serialize(writer, null);
                return;
            }
            var value = (long)untypedValue;
            serializer.Serialize(writer, value.ToString());
            return;
        }

        public static readonly ParseStringConverter Singleton = new ParseStringConverter();
    }
}

And how I obtained the data:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Encodings;

namespace WebApi1.Patreon.Controllers
{
    [ApiController]
    public class PatreonPledgesController : ControllerBase
    {
        private readonly ILogger<PatreonPledgesController> _logger;

        public PatreonPledgesController(ILogger<PatreonPledgesController> logger)
        {
            _logger = logger;
        }

        [HttpPost("pledges")]
        public IActionResult GetPatreonPledges([FromBody] PatreonRequestData request)
        {
            // must replace request with real raw string request data.
            // Request.EnableBuffering();
            // using (var streamReader = new StreamReader(Request.Body, Encoding.UTF8))
            // {
            //     request = await streamReader.ReadToEndAsync();
            // }

            if (!string.IsNullOrEmpty(Request.Headers["X-Patreon-Event"]))
            {
                string trigger = Request.Headers["X-Patreon-Event"];

                // do stuff for each trigger.
                return this.ProcessTriggers(trigger, request);
            }
            else
            {
                return this.BadRequest();
            }
        }

        private IActionResult ProcessTriggers(string trigger, PatreonRequestData request)
        {
            this.FilterDiscordInfo(trigger, request);
            var myUniqueFileName = $@"{DateTime.Now.Ticks}.json";
            using (var file = new FileStream(myUniqueFileName, FileMode.Create))
            {
                file.Write(Encoding.UTF8.GetBytes(request.ToJson()), 0, request.ToJson().Length);
            }

            // ensure this is logged to get more info.
            // _logger.Log(LogLevel.Information, request, Array.Empty<object>());
            // return this.NotFound();
            return this.Ok();
        }

        private void FilterDiscordInfo(string trigger, PatreonRequestData request)
        {
            string creatorDiscordServerId = request.Included[0].Attributes.DiscordServerId?.ToString();
            // for each user filter the values to see if their payment went through or not (aka are valid patrons).
            foreach (var included in request.Included)
            {
                // exclude the 1st item which is the creator object.
                if (!included.Equals(request.Included[0]))
                {
                    #region current triggers.
                    if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberCreate)))
                    {

                    }
                    else if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberUpdate)))
                    {

                    }
                    else if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberDelete)))
                    {

                    }
                    else if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberPledgeCreate)))
                    {

                    }
                    else if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberPledgeUpdate)))
                    {

                    }
                    else if (trigger.Equals(PledgeTypeHelper.GetStringType(PledgeType.MemberPledgeDelete)))
                    {

                    }
                    #endregion current triggers.
                }
            }
        }
    }
}