C# and .NET
Call the CEEJAY API from a C# server, check what CEEJAY sends you, and add Connect and the game loop to a C# game.
Two libraries for C#. Ceejay is for your own server, and uses your secret key. Ceejay.Connect is the game core: it is for a game, uses the game's public key, and has no reference to any engine. Unity has a package of its own, built on the game core: see Unity.
Both packages are published on NuGet: Ceejay and Ceejay.Connect.
They send the same requests as the TypeScript SDK, with the same retry rules, the same errors and the same defaults, and they are generated from the same OpenAPI spec as the API reference.
| Package | Target | For | Uses |
|---|---|---|---|
Ceejay | netstandard2.1, net8.0 | Your server: ASP.NET Core, a worker or a console tool | Your secret key. One function for each of the 36 requests a studio's server makes, and signature verification for webhooks and shop deliveries |
Ceejay.Connect | netstandard2.1 | Your game: any C# game, and Unity through its package | The game's public key. Connect, and the game loop (quests, shop, locker, rank and the daily reward) |
Ceejay is built on Ceejay.Connect: the same sender, the same retry rules, the same errors. Install the two at the same version. A game never uses Ceejay: it is for a secret key, and a secret key must never be inside a game.
Install
dotnet add package Ceejay # a server
dotnet add package Ceejay.Connect # a gameCeejay depends on Ceejay.Connect and installs the same version. If your project also references Ceejay.Connect, keep the two versions the same.
Both libraries use Newtonsoft.Json, which is what Unity ships. A server that uses System.Text.Json is not affected: the libraries read and write their own JSON and give you plain classes.
A server: create the client
Create an API key in the dashboard under Settings → Developers → API Keys, grant it only the permissions it needs, and store it in an environment variable. A key belongs to one Discord server, so you never pass a server ID.
using Ceejay;
using Ceejay.Models;
var ceejay = new CeejayClient(new CeejayOptions { ApiKey = Environment.GetEnvironmentVariable("CEEJAY_API_KEY") });Create one CeejayClient and reuse it for the life of your server. It is safe to share between threads. A missing key, or one that is not a CEEJAY key, is rejected when the client is created, and the error never repeats what was passed.
Prop
Type
Transport, Delay and Now are there for tests. A dedicated instance is covered on Dedicated instances.
What a function takes and returns
- There is one function for each request, grouped as the API reference groups them:
Community,Members,Xp(andXp.Program),Programs,Economy,Quests,Roles,Channels,Messages,Events,Shop,Connect,PlayersandFieldMap. Every function ends inAsync. The list of all 36 is on the SDK overview under its TypeScript names, which are the same names in camel case. - 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 URL path 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 grant, deduct or post, which addsIdempotencyKey. - A function returns what is inside
data. A function that lists returns the full response, withDataandPagealongside it. So doQuests.ReportTaskAsyncandShop.CompletePurchaseAsync, whose responses contain more thandata. - A field that is not required is nullable. A date is a
DateTimeOffset. A field that holds one of a fixed set of values (an enum) is astring, with the known values as constants next to it (PlayerQuest.StatusValues.InProgress), so a value that a later version adds is never rejected. - A Discord ID is a string of 17 to 20 digits. One that is not is rejected with an
ArgumentExceptionbefore anything is sent.
Read
Member member = await ceejay.Members.GetAsync("409876543210987654");
Console.WriteLine($"{member.DisplayName}, level {member.Level}, {member.Economy.Balance} coins");
ListQuestsResponse page = await ceejay.Quests.ListAsync(new ListQuestsParams { Limit = 20 });
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 });
}API reference: Get a member and List quests. For a member CEEJAY has never seen, the request fails with status 404 and the code member_not_found.
Write with an idempotency key
Every function that grants, deducts or posts takes a key in its WriteOptions. The key is a string you choose, 1 to 128 characters, for this one request. When the same request is sent again with its key, nothing is repeated: the response is the original one, with Duplicate set to true.
GrantCurrencyResult paid = await ceejay.Economy.GrantAsync(
new GrantCurrencyRequest { DiscordId = member.DiscordId, Amount = 50, Reason = "Match 881 prize" },
new WriteOptions { IdempotencyKey = "match-881-prize" });
Console.WriteLine($"{paid.AmountGranted} coins, balance {paid.Balance}, repeat: {paid.Duplicate}");API reference: Grant currency. Each key identifies one request: when one match grants coins and XP, send two keys, such as match-9:coins and match-9:xp. The key is sent as idempotencyKey, the one name that every request taking a key accepts. A key given to a request that takes none is rejected before anything is sent.
Retries
| Request | Retried after a 429, a 5xx or no response? |
|---|---|
| A read | Yes, up to MaxRetries times (2 by default, 10 at most) |
A write sent with an IdempotencyKey | Yes, with the same key. Also after the 409 that means the first request with the key is still running |
| A write sent without a key | Never. CEEJAY would apply it twice |
The wait is the Retry-After value, in the body or in the header (a date works too). When the response has no Retry-After, the client backs off, with a wait that grows from half a second to eight seconds. A Retry-After of more than a minute is not waited for: the error is thrown immediately. Errors and retries has the same rules for the TypeScript SDK.
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 */ }An error returned by the API is a CeejayError. It has Status, Code, Message, RetryAfter, RequestId (include it when you contact support about a request), Body (the full JSON, including the extra fields some errors add) and Reason, Field and Feature. Check the Code, not the message text. ErrorCodes is the closed list of codes, and Errors says what each one means.
No response at all is a CeejayConnectionError, with TimedOut and the InnerException. 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 an incorrect base URL returns.
Webhooks and shop deliveries
CEEJAY signs everything it sends. 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.VerifyDeliveryis for a shop item delivered by your API, and returns aShopDelivery. Respond to an invalid one with401, and CEEJAY refunds the member.- Both are synchronous, and throw a
CeejaySignatureErrorwhen the check fails. - A time more than 5 minutes from now (
VerifyOptions.Tolerance) is rejected, in either direction. So is a request with a changed body, time, delivery ID or attempt, and so is a request that carries only a legacy signature, unless you setAllowLegacytotrue. - The headers can be passed as a function (
name => …), a dictionary or a list of pairs, aNameValueCollection, or theHttpHeadersof anHttpRequestMessage.
The signature, the four headers and the rules for a repeated delivery are the same as for the TypeScript SDK: see Verifying signatures, Webhooks and Shop delivery.
A game: Connect and the game loop
Ceejay.Connect is the game core. It is configured with the game's public key (ceejay_pk_…) and draws nothing: your game shows the button, the link, the code and the QR image. A secret key is rejected, and never named in the message. The flow, the proof levels and the player's confirmation are explained on Connect and, for the TypeScript client that the game core follows request for request, on Connect, inside a game.
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, rolesShow, secureStore, myBrowserOpener and AskThePlayerAsync are yours. The rest of the example is the library.
- Player confirmation is required.
WaitForResultAsynctakes a function that shows the Discord name and returns true or false. It is required, because a code that is shown on a stream can be approved by anyone who reads it, and this is where the player can see a name that is not theirs and decline. - The four states of the button are
ConnectState.NotConnected,Waiting,ConnectedandNeedsAttention.connect.Attentionsays why a connected player needs attention:NotMember,Left,PendingorSharing. Redraw fromOnChange, which returns anIDisposable, or from theStateChangedevent. - Three interfaces are yours to implement.
IHttpTransportis what sends (HttpClientTransportis the default).IConnectStorageis where the refresh token and an open session are stored: use the device's secure store.MemoryConnectStorageandDelegateConnectStorageare included.ILinkOpeneris how the link is opened: never in a web view inside the game, because Discord blocks sign-in there. - Threads. The client preserves the caller's context across its awaits, so in an engine that needs one thread (Unity), the callbacks run where the game called it from.
PollNow()polls immediately, andSetInForeground(false)pauses polling while the game is in the background on a phone. - Timings and retries. The client polls for the result every 3 seconds, renews the player session a minute before it expires, and retries twice after a failure, the same as the TypeScript client. Start connect and Check in are never retried. The client sends only two headers,
AuthorizationandContent-Type. - The stored text is the JSON under the key
ceejay.connectthat the TypeScript client and the game kits store, so any of them can read it.
The game loop
Once the player is connected and the community has switched the loop on for your game, connect.Hub has the eleven requests of the game loop. The gates, the order of a first screen and the rules for spending are on The game loop, inside a game.
ListResponse<PlayerQuest> quests = await connect.Hub.ListQuestsAsync();
PlayerBalance balance = await connect.Hub.GetBalanceAsync(); // grey out Buy when balance.Spending.MaySpend is false
PlayerPurchase bought = await connect.Hub.BuyAsync(itemId: 7, expectedPrice: 500);| C# | TypeScript | API reference |
|---|---|---|
Hub.ListQuestsAsync | connect.hub.listQuests | List the quests |
Hub.StartQuestAsync | connect.hub.startQuest | Start a quest |
Hub.VerifyQuestTaskAsync | connect.hub.verifyQuestTask | Verify a quest task |
Hub.AnswerQuestTaskAsync | connect.hub.answerQuestTask | Answer a quest task |
Hub.ListShopAsync | connect.hub.listShop | List the shop |
Hub.BuyAsync | connect.hub.buy | Buy a shop item |
Hub.ListLockerAsync | connect.hub.listLocker | Read the locker |
Hub.ClaimAsync | connect.hub.claim | Claim an item |
Hub.GetRanksAsync | connect.hub.getRanks | Get the ranks |
Hub.GetBalanceAsync | connect.hub.getBalance | Get the balance |
Hub.CheckInAsync | connect.hub.checkIn | Check in |
BuyAsync generates an idempotency key (a UUID) unless BuyOptions.IdempotencyKey is given, and sends every attempt of the call with that one key, so a dropped response never buys twice. After a 504 it waits for the first request to finish, as Retry-After specifies, for up to 75 seconds. A player who cannot spend yet (a new device is subject to a spending hold, and a game has a daily limit) is a CeejaySpendBlockedError: show "come back later", with HoldUntil or ResetsAt, and do not treat it as a failure. CheckInAsync is never retried by the client, so a repeat returns checkin_cooldown.
Names that differ from TypeScript
| 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 naming: PascalCase, and Async on what returns a Task |
options.signal | RequestOptions.CancellationToken | |
timeoutMs | Timeout, a TimeSpan | |
onChange(listener) returns a function | OnChange(listener) returns an IDisposable | Also the StateChanged event |
waitForResult(session, { onConfirm }) | WaitForResultAsync(onConfirm, serverSession) | The confirmation callback comes first, because it is required |
TypeError for a mistake in use | ArgumentException, ArgumentNullException or 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') | A string with constants (PlayerQuest.StatusValues.InProgress) | A new value is never rejected |
The errors keep the names used by the other SDKs: CeejayError, CeejayConnectionError, CeejayNotAvailableError, CeejaySpendBlockedError and CeejaySignatureError.
Related
- Unity wraps the game core with Unity's network, browser and storage.
- Connect, The game loop and Errors explain what the requests do.
- TypeScript SDK, Connect, inside a game and The game loop, inside a game are the references the C# libraries follow.

