Ceejay.Connect 0.1.0

dotnet add package Ceejay.Connect --version 0.1.0
                    
NuGet\Install-Package Ceejay.Connect -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ceejay.Connect" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ceejay.Connect" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Ceejay.Connect" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ceejay.Connect --version 0.1.0
                    
#r "nuget: Ceejay.Connect, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ceejay.Connect@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ceejay.Connect&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Ceejay.Connect&version=0.1.0
                    
Install as a Cake Tool

CEEJAY for .NET

Call the CEEJAY API from your C# server, verify the webhooks CEEJAY sends, and link the players of your C# game to their Discord accounts.

Package Target For Holds
Ceejay netstandard2.1, net8.0 A studio's own server (ASP.NET Core, a worker, a console tool) A secret key. One function for each of the 36 requests a studio's server makes, and the checks of what CEEJAY sends back (webhooks and shop deliveries)
Ceejay.Connect netstandard2.1 The game: any C# game The game's public key. Connect (link a player to their Discord account) and the game loop (quests, shop, locker, rank, daily reward). No reference to any engine

Ceejay is built on Ceejay.Connect: the same sender, the same retry rules, the same errors. A game never needs Ceejay, and must not have it: it holds a secret key.

The full guide is at docs.communitystudios.xyz/sdk/dotnet, and the API reference at docs.communitystudios.xyz/docs.

Install

dotnet add package Ceejay           # a server
dotnet add package Ceejay.Connect   # a game

Ceejay brings in Ceejay.Connect at its own version. If your project also lists Ceejay.Connect, keep the two versions the same.

Both use Newtonsoft.Json, which is also what Unity ships. A server that uses System.Text.Json is not affected: the libraries read and write their own JSON and hand you plain classes.

A server: the example

using Ceejay;
using Ceejay.Models;

var ceejay = new CeejayClient(new CeejayOptions { ApiKey = Environment.GetEnvironmentVariable("CEEJAY_API_KEY") });

Member member = await ceejay.Members.GetAsync("409876543210987654");           // a read
GrantCurrencyResult paid = await ceejay.Economy.GrantAsync(                    // a write
    new GrantCurrencyRequest { DiscordId = member.DiscordId, Amount = 50, Reason = "Match 881 prize" },
    new WriteOptions { IdempotencyKey = "match-881-prize" });                  // makes a repeat safe
Console.WriteLine($"{paid.AmountGranted} coins, balance {paid.Balance}, repeat: {paid.Duplicate}");

ListQuestsResponse page = await ceejay.Quests.ListAsync(new ListQuestsParams { Limit = 20 });   // a list: { Data, Page }
while (true)
{
    foreach (Quest quest in page.Data) Console.WriteLine(quest.Title);
    if (page.Page.NextCursor == null) break;
    page = await ceejay.Quests.ListAsync(new ListQuestsParams { Limit = 20, Cursor = page.Page.NextCursor });
}

Make one CeejayClient and keep it for the life of your server; it is safe to share between threads. Pass your own HttpClient (CeejayOptions.HttpClient), for IHttpClientFactory, a proxy or a test. The key goes in an environment variable, never in source. A missing key, or one that is not a CEEJAY key, is refused when the client is made, and the error never repeats what was passed.

What each function takes and returns

  • One function for each of the 36 requests in the API file, grouped as the API reference groups them: Community, Members, Xp (and Xp.Program), Programs, Economy, Quests, Roles, Channels, Messages, Events, Shop, Connect, Players, FieldMap. Every function ends in Async.
  • A read takes the member's Discord ID as a string. A write takes one object with the fields of the API reference, under the same names in PascalCase (DiscordId, Amount).
  • A value that is part of the address comes before the object: the game's slug, then the session or the player, then the body.
  • The last argument is the options: RequestOptions (CancellationToken, Timeout, MaxRetries), or WriteOptions for the 14 functions that give, take or post, which adds IdempotencyKey.
  • A function returns what is inside data. A function that lists returns the whole answer (Data and Page beside it), and so do Quests.ReportTaskAsync and Shop.CompletePurchaseAsync, whose answers hold more than data.
  • Fields that are not required are nullable. A date is a DateTimeOffset. A field that holds one of a fixed set of words is a string, with the known words as constants next to it (PlayerQuest.StatusValues.InProgress): a word a later version adds is never refused.
  • A Discord ID is a 17 to 20 digit string. One that is not is refused with an ArgumentException before anything is sent.

Retries and idempotency keys

Request Tried again after a 429, a 5xx or no answer?
A read Yes, up to MaxRetries times (2 by default, 10 at most)
A write sent with an IdempotencyKey Yes, with the same key, and after the 409 that says the first request with the key is still running
A write sent without a key Never. CEEJAY would apply it twice

The wait is what Retry-After says (in the body or in the header; a date works too), or grows from half a second to eight when it says nothing. A Retry-After longer than a minute is not waited for: the error comes back at once. One key is one request: two requests for one thing that happened need two keys, such as match-9:coins and match-9:xp. The key goes out as idempotencyKey, the one name every request that takes a key reads. A key given to a request that takes none is refused before anything is sent.

Errors

try { await ceejay.Economy.DeductAsync(new DeductCurrencyRequest { DiscordId = id, Amount = 500 }); }
catch (CeejayError error) when (error.Code == ErrorCodes.InsufficientBalance)
{
    Console.WriteLine($"short by {(int)error.Body!["requested"]! - (int)error.Body["currentBalance"]!}");
}
catch (CeejayConnectionError error) { /* no answer: the write may or may not have been applied */ }

A refusal is a CeejayError: Status, Code, Message, RetryAfter, RequestId (quote it when you ask CEEJAY about a request), Body (the whole JSON, with the extra fields some refusals add) and Reason, Field, Feature. Test the Code, never the words. The closed list of codes is ErrorCodes. No answer at all is a CeejayConnectionError (TimedOut, and InnerException, a plain copy of what the transport threw). Cancelling your token throws the OperationCanceledException you asked for. A mistake in how the library is used is an ordinary ArgumentException, ArgumentNullException or InvalidOperationException. No part of an error ever shows the key, whatever a server, a proxy or a wrong address answers, and whatever your transport puts in its own message.

Webhooks and shop deliveries

CEEJAY signs everything it sends. Every delivery carries signature version 2 in four headers, and the older formats beside it. Pass the body exactly as it arrived (read it as text or bytes before anything parses it):

app.MapPost("/ceejay/webhook", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    string body = await reader.ReadToEndAsync();
    try
    {
        WebhookEvent webhook = CeejayWebhooks.VerifyWebhook(body, name => request.Headers[name].FirstOrDefault(), Environment.GetEnvironmentVariable("CEEJAY_WEBHOOK_SECRET"));
        // Keep webhook.Delivery.Id with webhook.Delivery.Attempt for five minutes, and ignore a pair you have already handled.
        if (webhook.Event == "quest.completed") Console.WriteLine(webhook.DataAs<QuestCompletedData>().QuestTitle);
        return Results.Ok();
    }
    catch (CeejaySignatureError) { return Results.Unauthorized(); }   // .Reason says why: NoHeader, Malformed, Expired, Mismatch, NotJson
});
  • VerifyWebhook is for a webhook subscription, VerifyDelivery for a shop item delivered by your API (it returns a ShopDelivery; answer a bad one with 401, and CEEJAY refunds the member).
  • The signature is the HMAC-SHA256 of v2.<timestamp>.<delivery ID>.<attempt>.<body>, keyed with the secret as the text it is. A time more than 5 minutes from now (VerifyOptions.Tolerance) is refused, either way, so is a request with a changed body, time, delivery ID or attempt, and so is one that carries only an older signature unless you set AllowLegacy = true.
  • The headers can be passed as a function (name => …), a dictionary or list of pairs, a NameValueCollection, or the HttpHeaders of an HttpRequestMessage.

A game: Connect and the game loop

using Ceejay;
using Ceejay.Connect;

var connect = new CeejayConnect(new CeejayConnectOptions { PublicKey = "ceejay_pk_…", Storage = secureStore });
await connect.RestoreAsync();                                                // when the game starts

ConnectSession session = await connect.StartAsync(new StartParams { PlayerId = "p_19", PlayerName = "Nova77" });
Show(session.Code, session.QrUrl);                                           // always show the code too
await connect.OpenLinkAsync(session, myBrowserOpener);                       // an ILinkOpener

ConnectOutcome outcome = await connect.WaitForResultAsync(async (member, details) =>   // asks every 3 seconds
    await AskThePlayerAsync($"Connect to {member.DisplayName}?"));                      // Yes or No: there is no default
if (outcome.IsConnected) Show(outcome.Connected.Member);                     // name, level, coins, roles

ListResponse<PlayerQuest> quests = await connect.Hub.ListQuestsAsync();      // the game loop
PlayerPurchase bought = await connect.Hub.BuyAsync(itemId: 7, expectedPrice: 500);
  • The key is a public key (ceejay_pk_…). A secret key is refused, and never named in the message.
  • It sends two headers, Authorization and Content-Type, and no other; the same numbers as the TypeScript client (a result every 3 seconds, growing waits up to 30 seconds while CEEJAY cannot be reached, a player session renewed a minute before it ends, two tries again for everything but Start connect).
  • The four states of the button are ConnectState.NotConnected, Waiting, Connected and NeedsAttention, and connect.Attention says why a connected player needs attention (NotMember, Left, Pending, Sharing). Redraw from OnChange or the StateChanged event.
  • The game's confirmation has no default. WaitForResultAsync takes a function that shows the Discord name and answers true or false. It is required.
  • One renewal at a time, and the next refresh token is in the storage before the new player session is used.
  • connect.Hub has the 11 requests of the game loop. BuyAsync makes an idempotency key (a UUID) unless BuyOptions.IdempotencyKey is given, and sends every try of the call with that one key, so a lost answer never buys twice; after a 504 it waits for the first send as Retry-After says, for up to 75 seconds. A player who cannot spend yet is a CeejaySpendBlockedError. CheckInAsync is never sent again by the client: a repeat reads as checkin_cooldown.
  • Three interfaces are yours to implement: IHttpTransport (what sends; HttpClientTransport is the default), IConnectStorage (where the refresh token and an open session are kept; use the device's secure store; MemoryConnectStorage and DelegateConnectStorage are included) and ILinkOpener (how the link is opened: never in a web view inside the game, because Discord refuses to sign in there).
  • Threads. The client keeps the context of the caller across its awaits, so in an engine that needs one thread (Unity) the callbacks (onConfirm and the change listeners) run where the game called it from. PollNow() asks at once, SetInForeground(false) pauses the asking while a phone has the game in the background.
  • The text kept in the storage is the JSON under the key ceejay.connect that the TypeScript client and the four kits keep, so any of them can read it.

Differences from the TypeScript SDK

TypeScript C# Why
new Ceejay({ apiKey }) new CeejayClient(new CeejayOptions { ApiKey = … }) A class cannot share the name of its namespace
ceejay.members.get(id) ceejay.Members.GetAsync(id) .NET's naming: PascalCase, and Async on what returns a Task
options.signal RequestOptions.CancellationToken
timeoutMs Timeout (a TimeSpan)
CeejayConnect.start(…) StartAsync(…)
onChange(listener) returns a function OnChange(listener) returns an IDisposable; also the StateChanged event
waitForResult(session, { onConfirm }) WaitForResultAsync(onConfirm, serverSession) The question is the first argument, because it is required
TypeError for a mistake in use ArgumentException, ArgumentNullException, InvalidOperationException What a .NET developer expects
verifyWebhook(body, headers, secret) is async CeejayWebhooks.VerifyWebhook(…) is synchronous The HMAC is not asynchronous here
String unions ('in_progress') string with constants (PlayerQuest.StatusValues.InProgress) A new word is never refused
One RequestOptions for the server, one ConnectRequestOptions for the game One RequestOptions They hold the same three things

The names of the errors are CeejayError, CeejayConnectionError, CeejayNotAvailableError, CeejaySpendBlockedError and CeejaySignatureError: the same words as every other CEEJAY SDK and kit.

Licence

MIT. Copyright (c) 2026 Community Studios.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 0 11/12/2026