CEEJAYDocs

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.

PackageTargetForUses
Ceejaynetstandard2.1, net8.0Your server: ASP.NET Core, a worker or a console toolYour secret key. One function for each of the 36 requests a studio's server makes, and signature verification for webhooks and shop deliveries
Ceejay.Connectnetstandard2.1Your game: any C# game, and Unity through its packageThe 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

Add a package to your project
dotnet add package Ceejay           # a server
dotnet add package Ceejay.Connect   # a game

Ceejay 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.

Create the client
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 (and Xp.Program), Programs, Economy, Quests, Roles, Channels, Messages, Events, Shop, Connect, Players and FieldMap. Every function ends in Async. 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), or WriteOptions for the 14 functions that grant, deduct or post, which adds IdempotencyKey.
  • A function returns what is inside data. A function that lists returns the full response, with Data and Page alongside it. So do Quests.ReportTaskAsync and Shop.CompletePurchaseAsync, whose responses contain more than data.
  • 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 a string, 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 ArgumentException before anything is sent.

Read

Read a member and list quests
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.

Grant currency with a key
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

RequestRetried after a 429, a 5xx or no response?
A readYes, up to MaxRetries times (2 by default, 10 at most)
A write sent with an IdempotencyKeyYes, with the same key. Also after the 409 that means the first request with the key is still running
A write sent without a keyNever. 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

Handle an error
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.

Check a webhook (ASP.NET Core)
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 is for a shop item delivered by your API, and returns a ShopDelivery. Respond to an invalid one with 401, and CEEJAY refunds the member.
  • Both are synchronous, and throw a CeejaySignatureError when 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 set AllowLegacy to true.
  • The headers can be passed as a function (name => …), a dictionary or a list of pairs, a NameValueCollection, or the HttpHeaders of an HttpRequestMessage.

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.

Connect a player
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

Show, secureStore, myBrowserOpener and AskThePlayerAsync are yours. The rest of the example is the library.

  • Player confirmation is required. WaitForResultAsync takes 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, Connected and NeedsAttention. connect.Attention says why a connected player needs attention: NotMember, Left, Pending or Sharing. Redraw from OnChange, which returns an IDisposable, or from the StateChanged event.
  • Three interfaces are yours to implement. IHttpTransport is what sends (HttpClientTransport is the default). IConnectStorage is where the refresh token and an open session are stored: use the device's secure store. MemoryConnectStorage and DelegateConnectStorage are included. ILinkOpener is 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, and SetInForeground(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, Authorization and Content-Type.
  • The stored text is the JSON under the key ceejay.connect that 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.

Use the game loop
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#TypeScriptAPI reference
Hub.ListQuestsAsyncconnect.hub.listQuestsList the quests
Hub.StartQuestAsyncconnect.hub.startQuestStart a quest
Hub.VerifyQuestTaskAsyncconnect.hub.verifyQuestTaskVerify a quest task
Hub.AnswerQuestTaskAsyncconnect.hub.answerQuestTaskAnswer a quest task
Hub.ListShopAsyncconnect.hub.listShopList the shop
Hub.BuyAsyncconnect.hub.buyBuy a shop item
Hub.ListLockerAsyncconnect.hub.listLockerRead the locker
Hub.ClaimAsyncconnect.hub.claimClaim an item
Hub.GetRanksAsyncconnect.hub.getRanksGet the ranks
Hub.GetBalanceAsyncconnect.hub.getBalanceGet the balance
Hub.CheckInAsyncconnect.hub.checkInCheck 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

TypeScriptC#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.signalRequestOptions.CancellationToken
timeoutMsTimeout, a TimeSpan
onChange(listener) returns a functionOnChange(listener) returns an IDisposableAlso the StateChanged event
waitForResult(session, { onConfirm })WaitForResultAsync(onConfirm, serverSession)The confirmation callback comes first, because it is required
TypeError for a mistake in useArgumentException, ArgumentNullException or InvalidOperationExceptionWhat a .NET developer expects
verifyWebhook(body, headers, secret) is asyncCeejayWebhooks.VerifyWebhook(…) is synchronousThe 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.

On this page

Community StudiosPrivacyTerms