Ceejay 0.1.0
dotnet add package Ceejay --version 0.1.0
NuGet\Install-Package Ceejay -Version 0.1.0
<PackageReference Include="Ceejay" Version="0.1.0" />
<PackageVersion Include="Ceejay" Version="0.1.0" />
<PackageReference Include="Ceejay" />
paket add Ceejay --version 0.1.0
#r "nuget: Ceejay, 0.1.0"
#:package Ceejay@0.1.0
#addin nuget:?package=Ceejay&version=0.1.0
#tool nuget:?package=Ceejay&version=0.1.0
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(andXp.Program),Programs,Economy,Quests,Roles,Channels,Messages,Events,Shop,Connect,Players,FieldMap. Every function ends inAsync. - 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), orWriteOptionsfor the 14 functions that give, take or post, which addsIdempotencyKey. - A function returns what is inside
data. A function that lists returns the whole answer (DataandPagebeside it), and so doQuests.ReportTaskAsyncandShop.CompletePurchaseAsync, whose answers hold more thandata. - Fields that are not required are nullable. A date is a
DateTimeOffset. A field that holds one of a fixed set of words is astring, 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
ArgumentExceptionbefore 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
});
VerifyWebhookis for a webhook subscription,VerifyDeliveryfor a shop item delivered by your API (it returns aShopDelivery; answer a bad one with401, 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 setAllowLegacy = true. - The headers can be passed as a function (
name => …), a dictionary or list of pairs, aNameValueCollection, or theHttpHeadersof anHttpRequestMessage.
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,
AuthorizationandContent-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,ConnectedandNeedsAttention, andconnect.Attentionsays why a connected player needs attention (NotMember,Left,Pending,Sharing). Redraw fromOnChangeor theStateChangedevent. - The game's confirmation has no default.
WaitForResultAsynctakes 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.Hubhas the 11 requests of the game loop.BuyAsyncmakes an idempotency key (a UUID) unlessBuyOptions.IdempotencyKeyis given, and sends every try of the call with that one key, so a lost answer never buys twice; after a504it waits for the first send asRetry-Aftersays, for up to 75 seconds. A player who cannot spend yet is aCeejaySpendBlockedError.CheckInAsyncis never sent again by the client: a repeat reads ascheckin_cooldown.- Three interfaces are yours to implement:
IHttpTransport(what sends;HttpClientTransportis the default),IConnectStorage(where the refresh token and an open session are kept; use the device's secure store;MemoryConnectStorageandDelegateConnectStorageare included) andILinkOpener(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 (
onConfirmand 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.connectthat 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 | Versions 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 is compatible. 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. |
-
.NETStandard 2.1
- Ceejay.Connect (>= 0.1.0)
-
net8.0
- Ceejay.Connect (>= 0.1.0)
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 |