Lingara Lingara Mwongozo Miongozo API Maktaba Programu Unda Pachika Programu ya wavuti
Lugha: Kiswahili

Vifurushi vya seva

Ukurasa huu umetafsiriwa kutoka Kiingereza. Ikiwa hizi mbili zinatofautiana, ukurasa wa Kiingereza ndio sahihi. Soma ukurasa wa Kiingereza

Seva yako ni nusu ya upachikaji inayoshikilia siri. Kifurushi cha seva hufunika maktaba ya Lingara ya lugha yako, kwa hivyo huingia kwa mteja wako wa OAuth na hutengeneza tokeni kwa wachezaji wako, hukagua webhooks ambazo Lingara hukutumia, na huwafuta wachezaji wanapoondoka. Mifano iliyo hapa chini inafuata hatua zilezile katika kila lugha.

Unda mteja wa malipo kwa matumizi

Unda mteja wa OAuth kwenye dashibodi na umweke kwenye mpango wa malipo kwa matumizi. Mpe embed:mint, ruhusa inayotengeneza tokeni na kufuta wachezaji. Mteja aliye kwenye mpango mwingine wowote hunyimwa ruhusa hiyo. Mpe pia kila ruhusa unayotaka tokeni za wachezaji wako zibebe, kwa sababu tokeni inaweza kushikilia tu ruhusa ambazo mteja wako anazo. embed:mint yenyewe haikabidhiwi kamwe kwa mchezaji.

Hifadhi kitambulisho (ID) na siri ya mteja katika mazingira ya seva yako, kamwe si ndani ya mchezo au ukurasa.

Sajili asili zako za wavuti

Wijeti ya tovuti inahitaji tokeni iliyofungwa kwa asili (origin) ya ukurasa. Kwenye dashibodi, orodhesha katika mipangilio ya mteja wako kila asili ambayo wijeti yako inaendeshwa juu yake, kama vile https://www.example.com. Unaweza kuorodhesha hadi 10. Asili ni scheme, host na port ya hiari, bila path. Kwa uundaji, asili ya loopback kwenye port yako mwenyewe inaruhusiwa pia, kama vile http://localhost:5173. Tokeni hukubaliwa tu kutoka kwa asili kamili ambayo ilitengenezwa kwa ajili yake, kwa hivyo tokeni iliyotengenezwa kwa asili nyingine yoyote hukataliwa na API. Mchezo hauhitaji asili.

Tengeneza tokeni

Tengeneza tokeni nyuma ya mfumo wako mwenyewe wa kuingia: njia yako ya tokeni (token route) hukagua kwanza kipindi chako mwenyewe, na hapo tu ndipo hutengeneza tokeni kwa mchezaji huyo. Weka kikomo cha kasi kwa njia hiyo kwa kila kipindi. Ombi la kutengeneza tokeni huchukua sehemu tatu:

  • player_ref: rejeleo lako mwenyewe la mchezaji, baiti 1 hadi 128 za UTF-8. Lingara huhifadhi hash yake tu.
  • scopes: hadi 5, kutoka kwa zile ambazo tokeni ya mchezaji inaweza kushikilia: events:read, events:write, lesson_plans:read, lesson_plans:write na embed:play. Ukiiacha, tokeni hushikilia kila moja kati ya hizo ambayo mteja wako anayo.
  • origin: kwa tovuti, asili ya ukurasa, ambayo lazima iwe mojawapo uliyosajili. Iache kwa mchezo.

Jibu lina token, expires_at, expires_in (muda uleule wa kuisha kwa sekunde, kwa kifaa ambacho saa yake huwezi kuiamini), subject, scopes za tokeni, na account_linked, inayosema kama mchezaji ameunganisha akaunti yake mwenyewe ya Lingara. subject humtambulisha mchezaji kwa mteja wako, na kila tukio kumhusu huibeba.

Mkabidhi mchezaji tokeni na muda wake wa kuisha kama vile makabidhiano ya kifurushi yanavyoviandika hasa, kupitia kipindi chako mwenyewe na kamwe si ndani ya URL, na utume jibu pamoja na Cache-Control: no-store. Kifurushi cha mchezaji huomba tena tokeni inapokaribia kuisha; tokeni haifanywi upya kamwe.

Elekeza webhook kwa mchezaji wake

Lingara hutia sahihi kila webhook inayokutumia. Kipe kifurushi mwili ghafi wa ombi, kabla ya uchanganuzi wowote wa JSON, kwa sababu sahihi inashughulikia baiti zake. Kifurushi hukagua sahihi na humtafuta mchezaji ambaye subject ya tukio inamtaja katika jedwali lako mwenyewe, kando ya wachezaji wako, linalolinganisha kila subject na player_ref yako. Mchezaji anayeunganisha akaunti humiliki subject mbili, kwa hivyo weka safu ya subject iwe ya kipekee na uruhusu player_ref moja kuonekana mara mbili.

Uwasilishaji ni angalau mara moja: ruka kitambulisho cha tukio ambacho tayari umeshughulikia. Jibu ndani ya sekunde 10, na uweke chochote cha polepole kwenye foleni yako mwenyewe.

Futa mchezaji

Kutoka kwa njia yako mwenyewe ya kufuta akaunti, mfute mchezaji kwa player_ref yake. Lingara hubatilisha kila tokeni ambayo mchezaji anashikilia na huondoa kila kitu inachohifadhi kwa ajili yake. Kufuta mchezaji asiyejulikana hufaulu pia, kwa hivyo mwito huo ni salama kurudiwa. Kufuta data yako mwenyewe kunabaki kuwa kazi yako.

Mchezaji anayeleta akaunti yake mwenyewe ya Lingara

Mchezaji mwenye akaunti yake mwenyewe ya Lingara anaweza kuiunganisha, ili mazoezi na mipango yake viwe kwenye akaunti hiyo. Bado hakuna kifurushi kinachofunika hili; ni miito miwili ya API kutoka kwa seva yako.

Seva yako huendesha mtiririko wa authorization-code kwa mteja yule yule anayetengeneza tokeni, ukitumia PKCE: mchezaji huingia na kukubali kwenye kivinjari chake, kamwe si ndani ya mchezo wako, na seva yako hubadilisha msimbo kwa siri yake. Katika idhini hiyo, omba embed:play na kila ruhusa unayotengeneza nayo tokeni, na ujumuishe events:read, la sivyo hutasikia chochote kuhusu mchezaji baada ya kuunganisha.

Kisha ita linkEmbedPlayer kwa tokeni iliyokabidhiwa ambayo ubadilishanaji huo ulirudisha, kamwe si tokeni ya mteja wako mwenyewe. Jibu lake lina:

  • status: linked wakati mwito huu ndio ulioweka muunganisho, existing wakati ulikuwa tayari hai;
  • subject, subject ya akaunti, ambayo matukio ya mchezaji huibeba kuanzia sasa;
  • previous_subject, subject ya mgeni, ambayo matukio yake ya awali huendelea kuibeba;
  • linked_at, wakati muunganisho ulipowekwa;
  • scopes, ruhusa nyingi zaidi ambazo tokeni ya mchezaji huyu inaweza kushikilia sasa;
  • carried, safu ambazo mwito huu ulihamishia kwenye akaunti, kwa kila jedwali;
  • kept, safu ulizoacha kwa mgeni, kama vile mpango ambao bado unaandikwa.

Kuunganisha ni salama kurudiwa, na kurudia hukamilisha kuhamisha kile ambacho kept inaripoti. Elekeza matukio yanayobeba subject yoyote kati ya hizo kwa player_ref ileile. Utakapotengeneza tokeni inayofuata kwa mchezaji huyo, jibu litakuwa na account_linked ikiwa true, na bado unalipia kile ambacho mchezaji hufanya.

Ili kumaliza muunganisho, ita unlinkEmbedPlayer kwa tokeni ya mteja wako mwenyewe. Hakuna kinachorudishwa kwa mgeni. Mwanafunzi anaweza pia kumaliza muunganisho mwenyewe, kwa kuondoa programu yako katika Programu zilizounganishwa.

Lugha ya mfano wa msimbo

TypeScript

npm i @lingara/embed@0.1.0-alpha.6 @lingara/api

Rust

cargo add lingara-embed@0.1.0-alpha.6 --features server
cargo add lingara tokio --features tokio/full

Go

go get github.com/Spinning-Cat-Studios/lingara_embeddable_sdk/go@v0.1.0-alpha.6

Java

<dependency>
  <groupId>com.getlingara</groupId>
  <artifactId>lingara-embed-server-java</artifactId>
  <version>0.1.0-alpha.6</version>
</dependency>

Kotlin

implementation("com.getlingara:lingara-embed-server-kotlin:0.1.0-alpha.6")

Ruby

gem install lingara-embed -v 0.1.0-alpha.6
# or, in a Gemfile:
# gem "lingara-embed", "0.1.0-alpha.6"

PHP

composer require spinningcatstudios/lingara-embed:0.1.0-alpha.6 spinningcatstudios/lingara:@alpha
# The lingara library sends through Symfony HttpClient (or Guzzle 7):
composer require symfony/http-client nyholm/psr7

C#

<PackageReference Include="Lingara.Embed.Server" Version="0.1.0-alpha.6" />
Lugha ya mfano wa msimbo

TypeScript

const lingara = new Lingara({ clientId: process.env.LINGARA_CLIENT_ID, clientSecret: process.env.LINGARA_CLIENT_SECRET })
const server = new EmbedServer(lingara, { directory })

// POST /lingara/token, behind your own sign-in. Rate-limit it per session.
export async function token(request: Request): Promise<Response> {
  const user = await signedInUser(request)
  if (user === null) return new Response(null, { status: 401 })
  const minted = await server.mintToken(user.id, { origin: 'https://www.example.com' })
  return new Response(tokenHandoff(minted), { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
}

Rust

/// `POST /lingara/token`: your own route, behind your own session, and
/// rate-limited per session. The widget calls it with same-origin cookies.
pub async fn token_route(State(app): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    let Some(player_ref) = current_player(&headers) else {
        return (StatusCode::UNAUTHORIZED, [(header::CACHE_CONTROL, "no-store")], String::new());
    };
    // This page's origin, one of your client's embed origins. A game's own
    // server mints without one.
    let options = MintOptions { origin: Some("https://game.example".into()), ..MintOptions::default() };
    match app.embed.mint_token(&player_ref, options).await {
        // {"token":"lgr_et_…","expires_at":"…"}
        Ok(minted) => (StatusCode::OK, [(header::CACHE_CONTROL, "no-store")], token_handoff(&minted)),
        Err(_) => (StatusCode::BAD_GATEWAY, [(header::CACHE_CONTROL, "no-store")], String::new()),
    }
}

Go

// POST /lingara/token: your own route, behind your own session, and
// rate-limited per session. The widget calls it with same-origin cookies.
return func(w http.ResponseWriter, r *http.Request) {
	playerRef, ok := currentPlayer(r) // your user, never a value from the request body
	if !ok {
		http.Error(w, "sign in first", http.StatusUnauthorized)
		return
	}
	minted, err := embed.MintToken(r.Context(), playerRef, lingaraembed.MintOptions{
		Origin: "https://game.example", // this page's origin, one of your client's embed origins
	})
	if err != nil {
		http.Error(w, "no token", http.StatusBadGateway)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	_, _ = w.Write(lingaraembed.TokenHandoff(minted)) // {"token":"lgr_et_…","expires_at":"…"}
}

Java

import com.getlingara.client.LingaraClient;
import com.getlingara.client.MintedToken;
import com.getlingara.embed.server.EmbedServer;
import com.getlingara.embed.server.MintOptions;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.TokenHandoff;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
LingaraClient client =
    LingaraClient.builder()
        .clientCredentials(
            System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
        .build();
EmbedServer embed = EmbedServer.of(client, players);

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
String playerId = currentPlayer(exchange);
if (playerId == null) {
  exchange.sendResponseHeaders(401, -1);
  return;
}
MintedToken minted =
    embed.mintToken(playerId, MintOptions.none().withOrigin("https://game.example"));
byte[] body = TokenHandoff.body(minted).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(200, body.length); // {"token":"lgr_et_…","expires_at":"…"}
exchange.getResponseBody().write(body);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.EmbedServer
import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.tokenHandoff
import com.getlingara.kotlin.LingaraClient
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
val client =
    LingaraClient {
        clientCredentials(System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
    }
val embed = EmbedServer(client, players)

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
post("/lingara/token") {
    val playerId = call.request.headers["X-Example-Player"] ?: return@post call.respond(HttpStatusCode.Unauthorized)
    val minted = embed.mintToken(playerId, origin = "https://game.example")
    call.response.header(HttpHeaders.CacheControl, "no-store")
    call.respondText(tokenHandoff(minted), ContentType.Application.Json) // {"token":"lgr_et_…","expires_at":"…"}
}

Ruby

# POST /lingara/token, behind your own sign-in. Rate-limit it per session.
def self.token(env, server)
  player = env["warden"]&.user or return [401, {}, []]
  minted = server.mint_token(player.id.to_s, origin: "https://game.example")
  [200, {"content-type" => "application/json", "cache-control" => "no-store"}, [Lingara::Embed.token_handoff(minted)]]
end

PHP

use Lingara\Client;
use Lingara\Embed\EmbedServer;
use Lingara\Embed\TokenHandoff;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
function embedServer(\PDO $db): EmbedServer
{
    $client = new Client(
        clientId: getenv('LINGARA_CLIENT_ID') ?: null,
        clientSecret: getenv('LINGARA_CLIENT_SECRET') ?: null,
    );
    return new EmbedServer($client, new PdoPlayerDirectory($db));
}

// POST /lingara/token: your own route, behind your own login. Rate-limit it
// per session; every call is one mint.
function tokenRoute(EmbedServer $embed): void
{
    session_start();
    $playerId = $_SESSION['player_id'] ?? null;
    if (!is_string($playerId)) {
        http_response_code(401);
        return;
    }
    $minted = $embed->mintToken($playerId, origin: 'https://game.example');
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo TokenHandoff::body($minted); // {"token":"lgr_et_…","expires_at":"…"}
}

C#

// One EmbedServer for the process: it caches the client-credentials token.
var lingara = new EmbedServer(new EmbedServerOptions
{
    ClientId = Environment.GetEnvironmentVariable("LINGARA_CLIENT_ID")!,         // lgr_cid_…
    ClientSecret = Environment.GetEnvironmentVariable("LINGARA_CLIENT_SECRET")!, // lgr_cs_…, from your secret store
}, new SqlPlayerDirectory(db));

// Your own session says who the player is. Rate-limit this route per session.
app.MapPost("/lingara/token", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier);
    if (playerRef is null)
    {
        return Results.Unauthorized();
    }

    // This page's origin, one of your client's embed origins. A game's server omits it.
    var minted = await lingara.MintTokenAsync(playerRef, new MintOptions { Origin = "https://game.example" }, ct);
    return Results.Text(TokenHandoff.Body(minted), "application/json"); // {"token":"lgr_et_…","expires_at":"…"}
}).RequireAuthorization().RequireRateLimiting("lingara-token");
Lugha ya mfano wa msimbo

TypeScript

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their rows. Deleting your own data stays yours.
export async function deleteAccount(userId: string): Promise<void> {
  await server.deletePlayer(userId)
}

Rust

// Revokes every token the player holds, then forgets their subjects.
// An unknown player is a success; on a failure the row is kept.
app.embed.delete_player(player_ref).await?;

Go

// From your own account-deletion path: revokes every token the player
// holds and forgets their subjects. An unknown player is a success.
if err := embed.DeletePlayer(r.Context(), playerRef); err != nil {
	var apiErr *lingara.APIError
	if errors.As(err, &apiErr) {
		return apiErr // the library's own error: Status, Code, RetryAfter
	}
	return err
}

Java

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId);

Kotlin

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId)

Ruby

# From your account-deletion path: revokes the player's tokens at Lingara
# and forgets their rows. An unknown player is a success.
def self.delete_account(server, player)
  server.delete_player(player.id.to_s)
end

PHP

use Lingara\Embed\EmbedServer;

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success too,
// so a retry is safe; on a failure the rows are kept.
function deleteAccount(EmbedServer $embed, \PDO $db, string $playerId): void
{
    $embed->deletePlayer($playerId);
    $delete = $db->prepare('DELETE FROM players WHERE id = ?') ?: throw new \RuntimeException('cannot prepare');
    $delete->execute([$playerId]);
}

C#

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success.
app.MapDelete("/account", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    await lingara.DeletePlayerAsync(playerRef, ct);
    // …then delete your own rows for this player.
    return Results.NoContent();
}).RequireAuthorization();
Lugha ya mfano wa msimbo

TypeScript

// Your own table, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE);
//   CREATE INDEX ON lingara_players (player_ref);
class PlayerTable implements PlayerDirectory {
  async remember(playerRef: string, subject: string): Promise<void> {
    await db.query('INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1', [playerRef, subject])
  }
  async playerRefFor(subject: string): Promise<string | undefined> {
    const rows = await db.query<{ player_ref: string }>('SELECT player_ref FROM lingara_players WHERE subject = $1', [subject])
    return rows[0]?.player_ref
  }
  async forget(playerRef: string): Promise<void> {
    await db.query('DELETE FROM lingara_players WHERE player_ref = $1', [playerRef])
  }
}

const directory = new PlayerTable()
const router = new PlayerRouter({ secrets: process.env.LINGARA_WEBHOOK_SECRET ?? '', directory })

// POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
export async function webhook(request: Request): Promise<Response> {
  const body = new Uint8Array(await request.arrayBuffer())
  const status = await router.handle(body, request.headers, async ({ event, playerRef }) => {
    // Delivery is at least once: skip an event.id you have already seen.
    // Answer within 10 s; enqueue anything slow and return.
    if (playerRef !== null && event.type === 'practice.completed') await practiceJobs.enqueue(playerRef, event.id)
  })
  return new Response(null, { status })
}

Rust

// Every live secret: two during a rotation, so either one verifies.
let webhook = lingara::events::Webhook::with_secrets(env("LINGARA_WEBHOOK_SECRETS").split(','))?;
let router = PlayerRouter::new(webhook, players);

/// `POST /lingara/webhook`: the **raw** body, before any JSON parsing.
/// 204 handled, 401 a bad signature, 400 a malformed payload, 500 your
/// handler failed. Lingara redelivers every non-2xx; dedupe on the event id.
pub async fn webhook_route(State(app): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
    let status = app
        .router
        .handle(&body, &headers, |routed| async move {
            match routed.player_ref {
                Some(player) => println!("{} for {player}", routed.event.event_type()),
                None => println!("{} for no player of ours", routed.event.event_type()),
            }
            Ok(()) // a slow handler enqueues and returns: delivery expects a 2xx within 10 s
        })
        .await;
    StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}

/// Your own store, beside your player rows: `player_ref`, and `subject`
/// with a unique index (a player who links an account owns two subjects).
pub struct SqlPlayerDirectory {
    pub pool: Arc<tokio::sync::Mutex<Vec<(String, String)>>>, // stands in for your database pool
}

impl PlayerDirectory for SqlPlayerDirectory {
    fn remember<'a>(&'a self, player_ref: &'a str, subject: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
        //   ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref
        Box::pin(async move {
            let mut rows = self.pool.lock().await;
            rows.retain(|(_, s)| s != subject);
            rows.push((player_ref.to_string(), subject.to_string()));
            Ok(())
        })
    }

    fn player_ref_for<'a>(&'a self, subject: &'a str) -> BoxFuture<'a, Result<Option<String>, DirectoryError>> {
        // SELECT player_ref FROM lingara_players WHERE subject = $1
        Box::pin(async move { Ok(self.pool.lock().await.iter().find(|(_, s)| s == subject).map(|(p, _)| p.clone())) })
    }

    fn forget<'a>(&'a self, player_ref: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // DELETE FROM lingara_players WHERE player_ref = $1
        Box::pin(async move {
            self.pool.lock().await.retain(|(p, _)| p != player_ref);
            Ok(())
        })
    }
}

Go

// lingara_players(player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE),
// with a plain index on player_ref: one player may own several subjects.
type sqlPlayers struct{ db *sql.DB }

func (p sqlPlayers) Remember(ctx context.Context, playerRef, subject string) error {
	_, err := p.db.ExecContext(ctx, `INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
		ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref`, playerRef, subject)
	return err
}

func (p sqlPlayers) PlayerRefFor(ctx context.Context, subject string) (string, bool, error) {
	var playerRef string
	err := p.db.QueryRowContext(ctx, `SELECT player_ref FROM lingara_players WHERE subject = $1`, subject).Scan(&playerRef)
	if errors.Is(err, sql.ErrNoRows) {
		return "", false, nil
	}
	return playerRef, err == nil, err
}

func (p sqlPlayers) Forget(ctx context.Context, playerRef string) error {
	_, err := p.db.ExecContext(ctx, `DELETE FROM lingara_players WHERE player_ref = $1`, playerRef)
	return err
}

// WebhookRoute is the webhook example.
func WebhookRoute(db *sql.DB) (http.HandlerFunc, error) {
	router, err := lingaraembed.NewPlayerRouter(sqlPlayers{db}, os.Getenv("LINGARA_WEBHOOK_SECRET"))
	if err != nil {
		return nil, err // a malformed lgr_whsec_… fails here, at startup
	}
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body) // the raw body: verify before parsing
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(router.Handle(r.Context(), body, r.Header, onEvent))
	}, nil
}

func onEvent(ctx context.Context, routed lingaraembed.RoutedEvent) error {
	// Delivery is at least once: skip an event.Meta().ID you have seen.
	if e, ok := routed.Event.(lingara.PracticeCompleted); ok && routed.Known {
		slog.InfoContext(ctx, "practice completed", "player", routed.PlayerRef, "plan", e.Data.PlanID)
	}
	return nil // a returned error answers 500, and Lingara redelivers
}

Java

import com.getlingara.client.events.PracticeCompleted;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.PlayerRouter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import javax.sql.DataSource;

/**
 * Your player directory, beside your player rows:
 *
 * <pre>
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * </pre>
 */
public record JdbcPlayerDirectory(DataSource db) implements PlayerDirectory {
  @Override
  public void remember(String playerRef, String subject) {
    update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?)"
            + " ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject);
  }

  @Override
  public Optional<String> playerRefFor(String subject) {
    try (Connection c = db.getConnection();
        PreparedStatement q =
            c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?")) {
      q.setString(1, subject);
      try (ResultSet row = q.executeQuery()) {
        return row.next() ? Optional.of(row.getString(1)) : Optional.empty();
      }
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }

  @Override
  public void forget(String playerRef) {
    update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef);
  }

  private void update(String sql, String... values) {
    try (Connection c = db.getConnection();
        PreparedStatement q = c.prepareStatement(sql)) {
      for (int i = 0; i < values.length; i++) {
        q.setString(i + 1, values[i]);
      }
      q.executeUpdate();
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
PlayerRouter router =
    PlayerRouter.of(new JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"));

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
byte[] body = exchange.getRequestBody().readAllBytes();
int status =
    router.handle(
        body,
        exchange.getRequestHeaders(),
        routed -> {
          // Delivery is at least once: skip an event.id you have seen.
          if (routed.event() instanceof PracticeCompleted done
              && routed.playerRef().isPresent()) {
            award(routed.playerRef().get(), done);
          }
        });
exchange.sendResponseHeaders(status, -1);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.PlayerRouter
import com.getlingara.kotlin.events.PracticeCompleted
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.sql.DataSource

/**
 * Your player directory, beside your player rows:
 *
 * ```sql
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * ```
 */
class JdbcPlayerDirectory(
    private val db: DataSource,
) : PlayerDirectory {
    override suspend fun remember(
        playerRef: String,
        subject: String,
    ) = update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) " +
            "ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject,
    )

    override suspend fun playerRefFor(subject: String): String? =
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?").use { q ->
                    q.setString(1, subject)
                    q.executeQuery().use { row -> if (row.next()) row.getString(1) else null }
                }
            }
        }

    override suspend fun forget(playerRef: String) = update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef)

    private suspend fun update(
        sql: String,
        vararg values: String,
    ) {
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement(sql).use { q ->
                    values.forEachIndexed { i, v -> q.setString(i + 1, v) }
                    q.executeUpdate()
                }
            }
        }
    }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
val router = PlayerRouter(JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"))

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
post("/lingara/webhook") {
    val headers =
        call.request.headers
            .entries()
            .associate { it.key to it.value }
    val status =
        router.handle(call.receive<ByteArray>(), headers) { routed ->
            // Delivery is at least once: skip an event.id you have seen.
            val event = routed.event
            val playerId = routed.playerRef
            if (event is PracticeCompleted && playerId != null) award(playerId, event)
        }
    call.respond(HttpStatusCode.fromValue(status))
}

Ruby

# Your own table: CREATE TABLE lingara_players (player_ref TEXT NOT NULL,
# subject TEXT NOT NULL UNIQUE); CREATE INDEX ON lingara_players (player_ref).
# Any object answering these three methods is a directory; this one is over
# Sequel (`db = Sequel.connect(ENV.fetch("DATABASE_URL"))`).
class PlayerTable
  def initialize(db)
    @rows = db[:lingara_players]
  end

  def remember(player_ref, subject)
    @rows.insert_conflict(target: :subject, update: {player_ref: player_ref})
      .insert(player_ref: player_ref, subject: subject)
  end

  def player_ref_for(subject)
    @rows.where(subject: subject).get(:player_ref)
  end

  def forget(player_ref)
    @rows.where(player_ref: player_ref).delete
  end
end

# POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
def self.webhook(env, router)
  status = router.handle(env["rack.input"].read, env) do |routed|
    # Delivery is at least once: skip an event.id you have already seen.
    # Answer within 10 s; enqueue anything slow and return.
    PracticeJobs.enqueue(routed.player_ref, routed.event) if routed.player_ref
  end
  [status, {}, []]
end

PHP

use Lingara\Embed\PlayerDirectory;
use Lingara\Embed\PlayerRouter;
use Lingara\Embed\RoutedEvent;
use Lingara\Events\Generated\PracticeCompleted;
use Psr\Http\Message\ServerRequestInterface;

// POST /lingara/webhook, from any PSR-7 stack (or pass getallheaders()).
// Read the body exactly as received. handle() returns the status to send,
// with an empty body: 204, 401 a bad signature, 400 a malformed payload, 500
// your handler or directory threw. Lingara redelivers every non-2xx, and
// expects the 2xx within 10 s: enqueue slow work and return.
function webhookRoute(ServerRequestInterface $request, \PDO $db): int
{
    $router = new PlayerRouter(new PdoPlayerDirectory($db), getenv('LINGARA_WEBHOOK_SECRET') ?: '');
    return $router->handle((string) $request->getBody(), $request, static function (RoutedEvent $routed) use ($db): void {
        $event = $routed->event;
        if (!$event instanceof PracticeCompleted || $routed->playerRef === null) {
            return; // your own subject, a deleted player, or another type
        }
        // Delivery is at least once: deduplicate on the event's id.
        $insert = $db->prepare('INSERT INTO practice (event_id, player_id, plan_id) VALUES (?, ?, ?) ON CONFLICT (event_id) DO NOTHING')
            ?: throw new \RuntimeException('cannot prepare');
        $insert->execute([$event->id, $routed->playerRef, $event->data->getPlanId()]);
    });
}

// The directory, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
//   CREATE UNIQUE INDEX ON lingara_players (subject);
//   CREATE INDEX ON lingara_players (player_ref);
final class PdoPlayerDirectory implements PlayerDirectory
{
    public function __construct(
        private readonly \PDO $db,
    ) {}

    public function remember(string $playerRef, string $subject): void
    {
        $this->run('INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) '
            . 'ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref', [$playerRef, $subject]);
    }

    public function playerRefFor(string $subject): ?string
    {
        $ref = $this->run('SELECT player_ref FROM lingara_players WHERE subject = ?', [$subject])->fetchColumn();
        return is_string($ref) ? $ref : null;
    }

    public function forget(string $playerRef): void
    {
        $this->run('DELETE FROM lingara_players WHERE player_ref = ?', [$playerRef]);
    }

    /** @param list<string> $args */
    private function run(string $sql, array $args): \PDOStatement
    {
        $statement = $this->db->prepare($sql) ?: throw new \RuntimeException('cannot prepare a statement');
        $statement->execute($args);
        return $statement;
    }
}

C#

public static partial class LingaraWebhook
{
    public static void MapLingaraWebhook(this WebApplication app, DbDataSource db)
    {
        // lgr_whsec_… from your secret store; pass both during a rotation.
        var router = new PlayerRouter(new SqlPlayerDirectory(db), Environment.GetEnvironmentVariable("LINGARA_WEBHOOK_SECRET")!);

        app.MapPost("/lingara/webhook", async (HttpRequest request, CancellationToken ct) =>
        {
            using var body = new MemoryStream();
            await request.Body.CopyToAsync(body, ct); // the raw bytes: never a re-serialised object
            var status = await router.HandleAsync(body.ToArray(), name => request.Headers[name].FirstOrDefault(), async (routed, token) =>
            {
                // Delivery is at least once: skip an Event.Id you have seen. Enqueue
                // slow work and return, since Lingara expects a 2xx within 10 s.
                if (routed.PlayerRef is { } player && routed.Event.Type == "practice.completed")
                {
                    await SaveProgressAsync(db, player, routed.Event.Id, routed.Event.Data.GetRawText(), token);
                }
            }, ct);
            return Results.StatusCode(status); // 204, 400, 401 or 500, always with an empty body
        });
    }
}

/// <summary>
/// Your player directory over your own database (PostgreSQL here):
/// <c>CREATE TABLE lingara_players (player_ref text NOT NULL, subject text PRIMARY KEY)</c>
/// with <c>CREATE INDEX ON lingara_players (player_ref)</c>. Unique on
/// <c>subject</c> only: a player who links an account owns two subjects.
/// </summary>
public sealed class SqlPlayerDirectory(DbDataSource db) : IPlayerDirectory
{
    public async ValueTask RememberAsync(string playerRef, string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1", playerRef, subject);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async ValueTask<string?> PlayerRefForAsync(string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "SELECT player_ref FROM lingara_players WHERE subject = $1", subject);
        return await cmd.ExecuteScalarAsync(ct) as string;
    }

    public async ValueTask ForgetAsync(string playerRef, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "DELETE FROM lingara_players WHERE player_ref = $1", playerRef);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    internal static DbCommand Command(DbDataSource db, string sql, params string[] values)
    {
        var cmd = db.CreateCommand(sql);
        foreach (var value in values)
        {
            var p = cmd.CreateParameter();
            p.Value = value;
            cmd.Parameters.Add(p);
        }

        return cmd;
    }
}

Tazama pia