Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4b1a5be
feat: add webhooks
null8626 Sep 16, 2025
490547d
feat: make it more similar to Node.js SDK' webhooks wrapper instead
null8626 Sep 18, 2025
6b9344a
Merge branch 'Top-gg-Community:master' into split/webhooks
null8626 Sep 29, 2025
e42795b
meta: make version 1.6.0 instead of 2.0.0 (reflect #32)
null8626 Sep 29, 2025
14397a2
revert: revert from 1.6.0 back to 2.0.0 (reflect #33)
null8626 Feb 3, 2026
e98dc57
feat: implement new webhook authorization approach
null8626 Feb 3, 2026
acbf9ed
deps: bump dependencies
null8626 Feb 3, 2026
1df58d9
refactor: reset bodyStream's Position back to 0 and re-read it
null8626 Feb 5, 2026
f787f56
refactor: use Deserialize instead of DeserializeAsync
null8626 Feb 5, 2026
92e7408
refactor: use HMACSHA256.HashData() instead
null8626 Feb 17, 2026
f087958
feat: properly update webhooks payloads
null8626 Feb 17, 2026
aee238a
fix: add PlatformConverter and ProjectTypeConverter to JSON serializer
null8626 Feb 17, 2026
7569ba4
refactor: use two generics to simplify webhook event listeners
null8626 Feb 17, 2026
c273936
[fix,feat]: use a webhook listener interface approach instead
null8626 Feb 17, 2026
20788ec
style: prettier
null8626 Feb 17, 2026
fbdf7b2
feat: add integration.create and integration.delete support
null8626 Feb 17, 2026
fcba5ae
refactor: make defaultResponse static
null8626 Feb 17, 2026
9f75602
fix: remove overridden 200
null8626 Feb 17, 2026
aeb8d6d
refactor: remove redundant JsonPropertyName
null8626 Feb 17, 2026
7e18989
feat: more consistent naming
null8626 Feb 17, 2026
5bdacfc
feat: return 500 upon callback exception
null8626 Feb 18, 2026
f827a91
[refactor,feat]: remove redundant imports and add I prefix to Webhook…
null8626 Feb 19, 2026
2154ad6
fix: deserialize created_at instead of voted_at
null8626 Mar 5, 2026
9df7f7d
refactor: rename Invalid Request to Bad Request
null8626 Mar 5, 2026
1cc977d
[feat,fix]: change property cases and make webhooks work
null8626 Mar 6, 2026
e20c7fe
feat: rename Api.Webhooks to Webhooks
null8626 Mar 6, 2026
4db0f67
feat: add working tests
null8626 Mar 7, 2026
2432188
meta: add .gitignore
null8626 Mar 7, 2026
2416438
feat: move and rename C# modules
null8626 Mar 7, 2026
280c065
meta: update project description and tags in project csproj
null8626 Mar 10, 2026
34576df
doc: add summary docstring for the webhooks class
null8626 Mar 10, 2026
c67e282
[meta,refactor,doc,style]: remove mentions of DBL, use non-scoped nam…
null8626 Mar 11, 2026
0c7b3e0
[refactor,feat]: use JsonStringEnumConverter, update projecttype
null8626 Mar 11, 2026
5724402
refactor: use PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
null8626 Mar 11, 2026
954ec11
feat: rename webhooks to WebhookEventListener
null8626 Mar 11, 2026
d55a8e4
meta: change project version to 1.0.0
null8626 Mar 11, 2026
b9105ba
meta: add more properties to csproj file
null8626 Mar 11, 2026
eb30b34
doc: clearer listener method documentation
null8626 Mar 11, 2026
6c347fd
fix: add proper x-topgg-trace checks and log warning + return 204 upo…
null8626 Mar 11, 2026
2955a9f
fix: impose webhooks body length constraints
null8626 Mar 11, 2026
c6bb544
feat: add webhook timeout handling
null8626 Mar 17, 2026
0b5b367
fix: return 408 in case of timeout
null8626 Mar 17, 2026
3431d12
feat: increase default timeout to 5 seconds
null8626 Mar 17, 2026
1660bd1
doc: simplify docs
null8626 Mar 18, 2026
957bb42
refactor: make secret attribute internal instead of public
null8626 Mar 18, 2026
1565241
doc: documentation tweaks
null8626 Mar 18, 2026
047cf40
feat: add replay attack mitigation
null8626 Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
*.user
*.json
*/.vs
.vs/
*/obj
Expand Down
24 changes: 24 additions & 0 deletions Topgg.Sdk.Webhooks/Data/PartialProject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using Topgg.Sdk.Webhooks.Serialization;

namespace Topgg.Sdk.Webhooks.Data;

/// <summary>A brief information on project listed on Top.gg.</summary>
public class PartialProject
{
/// <summary>The project's ID.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong Id { get; internal init; }

/// <summary>The project's ID.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProjectType Type { get; internal init; }

/// <summary>The project's platform.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public Platform Platform { get; internal init; }

/// <summary>The project's platform ID.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong PlatformId { get; internal init; }
}
7 changes: 7 additions & 0 deletions Topgg.Sdk.Webhooks/Data/Platform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Topgg.Sdk.Webhooks.Data;

/// <summary>A project's platform.</summary>
public enum Platform
{
Discord
}
8 changes: 8 additions & 0 deletions Topgg.Sdk.Webhooks/Data/ProjectType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Topgg.Sdk.Webhooks.Data;

/// <summary>A project's type.</summary>
public enum ProjectType
{
Bot,
Server
}
23 changes: 23 additions & 0 deletions Topgg.Sdk.Webhooks/Data/User.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
using Topgg.Sdk.Webhooks.Serialization;

namespace Topgg.Sdk.Webhooks.Data;

/// <summary>A Top.gg user.</summary>
public class User
{
/// <summary>The user's ID.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong Id { get; internal init; }

/// <summary>The user's name.</summary>
public string Name { get; internal init; }

/// <summary>The user's avatar URL.</summary>
[JsonPropertyName("avatar_url")]
public string Avatar { get; internal init; }

/// <summary>The user's platform ID.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong PlatformId { get; internal init; }
}
31 changes: 31 additions & 0 deletions Topgg.Sdk.Webhooks/Payloads/Integration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
using Topgg.Sdk.Webhooks.Data;
using Topgg.Sdk.Webhooks.Serialization;

namespace Topgg.Sdk.Webhooks.Payloads;

/// <summary>An `integration.create` webhook payload.</summary>
public class IntegrationCreatePayload
{
/// <summary>The unique identifier for this connection.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong ConnectionId { get; internal init; }

/// <summary>The secret used to verify future webhook deliveries.</summary>
[JsonPropertyName("webhook_secret")]
public string Secret { get; internal init; }

/// <summary>The project that the integration refers to.</summary>
public PartialProject Project { get; internal init; }

/// <summary>The user who triggered this event.</summary>
public User User { get; internal init; }
}

/// <summary>An `integration.delete` webhook payload.</summary>
public class IntegrationDeletePayload
{
/// <summary>The unique identifier for this connection.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong ConnectionId { get; internal init; }
}
10 changes: 10 additions & 0 deletions Topgg.Sdk.Webhooks/Payloads/Payload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json;

namespace Topgg.Sdk.Webhooks.Payloads;

internal class Payload
{
public string Type { get; init; }

public JsonElement Data { get; init; }
}
13 changes: 13 additions & 0 deletions Topgg.Sdk.Webhooks/Payloads/Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Topgg.Sdk.Webhooks.Data;

namespace Topgg.Sdk.Webhooks.Payloads;

/// <summary>A `webhook.test` webhook payload.</summary>
public class TestPayload
{
/// <summary>The project that the test refers to.</summary>=
public PartialProject Project { get; internal init; }

/// <summary>The user who triggered this test.</summary>=
public User User { get; internal init; }
}
30 changes: 30 additions & 0 deletions Topgg.Sdk.Webhooks/Payloads/VoteCreate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Text.Json.Serialization;
using Topgg.Sdk.Webhooks.Data;
using Topgg.Sdk.Webhooks.Serialization;

namespace Topgg.Sdk.Webhooks.Payloads;

/// <summary>A `vote.create` webhook payload.</summary>
public class VoteCreatePayload
{
/// <summary>The vote's ID.</summary>
[JsonConverter(typeof(ULongToStringConverter))]
public ulong Id { get; internal init; }

/// <summary>The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth.</summary>
public int Weight { get; internal init; }

/// <summary>When the vote was cast.</summary>
[JsonPropertyName("created_at")]
public DateTime VotedAt { get; internal init; }

/// <summary>When the vote expires (the user can vote again.)</summary>
public DateTime ExpiresAt { get; internal init; }

/// <summary>The project that received this vote.</summary>
public PartialProject Project { get; internal init; }

/// <summary>The user who voted for this project.</summary>
public User User { get; internal init; }
}
21 changes: 21 additions & 0 deletions Topgg.Sdk.Webhooks/Serialization/ULongToStringConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Topgg.Sdk.Webhooks.Serialization;

/// <summary>Converts API responses from strings to longs and vice versa.</summary>
internal class ULongToStringConverter : JsonConverter<ulong>
{
public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String && ulong.TryParse(reader.GetString(), out var value))
{
return value;
}

throw new InvalidOperationException();
}

public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString());
}
24 changes: 24 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/CustomWebhooks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Threading.Tasks;
using Topgg.Sdk.Webhooks.Payloads;
using Microsoft.AspNetCore.Http;

namespace Topgg.Sdk.Webhooks.Tests;

internal class CustomWebhooks() : WebhookEventListener(Mock.Secret)
{
public override Task OnIntegrationCreate(HttpContext context, IntegrationCreatePayload payload, string trace) => DefaultResponse("IntegrationCreate", context, trace);
public override Task OnIntegrationDelete(HttpContext context, IntegrationDeletePayload payload, string trace) => DefaultResponse("IntegrationDelete", context, trace);
public override Task OnTest(HttpContext context, TestPayload payload, string trace) => DefaultResponse("Test", context, trace);
public override Task OnVoteCreate(HttpContext context, VoteCreatePayload payload, string trace) => DefaultResponse("VoteCreate", context, trace);

private static async Task DefaultResponse(string name, HttpContext context, string trace)
{
if (!context.Response.HasStarted)
{
context.Response.StatusCode = 200;

await context.Response.WriteAsync($"{name},{trace}");
}
}
}
32 changes: 32 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Mock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace Topgg.Sdk.Webhooks.Tests;

internal class Mock
{
internal static readonly string Secret = "testsecret1234";

internal static readonly string Prefix = "Topgg.Sdk.Webhooks.Tests.Mocks.";

internal static string[] Names = [.. typeof(Mock).Assembly.GetManifestResourceNames().Where(name => name.StartsWith(Prefix)).Select(name => name[Prefix.Length..(name.Length - 5)])];

internal static string ReadJson(string name)
{
using var stream = typeof(Mock).Assembly.GetManifestResourceStream($"{Prefix}{name}.json");
using var reader = new StreamReader(stream);

return reader.ReadToEnd();
}

internal static string Signature(string body)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var hash = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.UTF8.GetBytes($"{timestamp}.{body}"))).ToLowerInvariant();

return $"t={timestamp},v1={hash}";
}
}
19 changes: 19 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Mocks/IntegrationCreate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"type": "integration.create",
"data": {
"connection_id": "112402021105124",
"webhook_secret": "whs_abcd",
"project": {
"id": "1230954036934033243",
"platform": "discord",
"platform_id": "3949456393249234923",
"type": "bot"
},
"user": {
"id": "3949456393249234923",
"platform_id": "3949456393249234923",
"name": "username",
"avatar_url": "<avatar url>"
}
}
}
6 changes: 6 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Mocks/IntegrationDelete.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "integration.delete",
"data": {
"connection_id": "112402021105124"
}
}
17 changes: 17 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Mocks/Test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"type": "webhook.test",
"data": {
"user": {
"id": "160105994217586689",
"platform_id": "160105994217586689",
"name": "username",
"avatar_url": "<avatar url>"
},
"project": {
"id": "803190510032756736",
"type": "bot",
"platform": "discord",
"platform_id": "160105994217586689"
}
}
}
21 changes: 21 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Mocks/VoteCreate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"type": "vote.create",
"data": {
"id": "808499215864008704",
"weight": 1,
"created_at": "2026-02-09T00:47:14.2510149+00:00",
"expires_at": "2026-02-09T12:47:14.2510149+00:00",
"project": {
"id": "803190510032756736",
"type": "bot",
"platform": "discord",
"platform_id": "160105994217586689"
},
"user": {
"id": "160105994217586689",
"platform_id": "160105994217586689",
"name": "username",
"avatar_url": "<avatar url>"
}
}
}
59 changes: 59 additions & 0 deletions Topgg.Sdk.Webhooks/Tests/Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Topgg.Sdk.Webhooks.Tests;

public class Tests
{
public static IEnumerable<TheoryDataRow<string>> Payloads => Mock.Names.Select(payload => new TheoryDataRow<string>(payload));

private static readonly string Trace = "trace";
private readonly HttpClient Http;

public Tests()
{
var server = new TestServer(new WebHostBuilder().ConfigureServices(services =>
{
services.AddRouting();
}).Configure(app =>
{
app.UseRouting();
app.UseEndpoints(builder =>
{
var webhooks = new CustomWebhooks();

builder.MapPost("/webhook", webhooks.Handler);
});
}));

Http = server.CreateClient();
}

[Theory]
[MemberData(nameof(Payloads))]
public async Task Test(string payload)
{
var body = Mock.ReadJson(payload);

var response = await Http.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
Headers = {
{"x-topgg-signature", Mock.Signature(body)},
{"x-topgg-trace", Trace}
}
}, TestContext.Current.CancellationToken);

Assert.StrictEqual(HttpStatusCode.OK, response.StatusCode);
Assert.Equal($"{payload},{Trace}", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
}
Loading