Receiving Eternaltwin events
Eternaltwin can push user events to an application, so it learns that a player changed their name or was disabled without waiting for their next OAuth sign-in. Eternalfest uses it in production.
- One HTTP
POSTper event, to a URL the application provides (a webhook). There is no SSE or websocket stream. - Only user events exist today (the
Usertopic). - An application only receives the events of users who already authorized it: users with an OAuth access token for one of its channels.
- The event only says which user changed. To get the new values, read the user back through the API.
This page assumes the application is already registered: see Eternaltwin for OAuth first.
Enable it
The owners of an application set the webhook URL of each channel on the application's edit page,
in the "Event webhook URL" field: an http or https URL, or nothing to disable the events. The
same field is the event_stream_callback_uri of a channel in PATCH /api/v1/apps/:app_id.
A fresh channel can also take its URL from its seed, next to its OAuth callback: the
event_stream_uri key of [seed.app.<key>].
[seed.app.mygame_dev] uri = "http://localhost:3000/" oauth_callback = "http://localhost:3000/oauth/callback" event_stream_uri = "http://localhost:3000/api/eternaltwin/events" secret = "dev"
The seed only fills the URL of a channel it creates: once the channel exists, the edit page owns
it, and a restart keeps what it holds even if the configuration changes. On eternaltwin.org,
enter a public HTTPS URL on the edit page.
The request
POST <event_stream_uri>
Authorization: Basic base64("eternaltwin.<channel>:<client secret>")
Content-Type: application/json
| Basic auth | Value |
|---|---|
| Login | eternaltwin.<channel>, where <channel> is the key of the application channel the event goes to: eternaltwin.main for the channel main, eternaltwin.staging for the channel staging. |
| Password | The OAuth client secret of the application channel (the secret of its seed). |
The body is the event as JSON:
{
"event": "UserUpdated",
"id": "0f6a2c39-8f3e-4d43-9b8e-1b0b6c4f7a21",
"time": "2026-10-19T14:03:12.481Z",
"user": {
"type": "User",
"id": "8a1c5f0e-2d6b-4b7a-a1f3-6c2e9d0b4e57"
}
}
event | Sent when |
|---|---|
UserUpdated | Something the apps read about the account changed: its display name, or a link to a Dinoparc, Hammerfest or Twinoid archive account (added or removed). A password or username change sends nothing. |
UserDisabled | The account was disabled. |
UserEnabled | The account was enabled again. |
id identifies the event, time is when it happened (UTC, millisecond precision), user.id is the
Eternaltwin user id the application already received at sign-in. The list of events is open: new
kinds may appear, so ignore the ones you do not know.
The response
- Any
2xxstatus means the event was delivered. Answer quickly (204) and do the work afterwards. - Any other status, a timeout (5 seconds) or a network error is a failure: the event is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours. After 6 failed attempts, the delivery is failed and the event is not sent any more.
- If the channel has no webhook URL any more when an attempt is due, the delivery is canceled.
- Past 100 000 outgoing requests over 12 hours, across all applications, sending waits one hour.
Delivery is at least once: after a restart of Eternaltwin, an event may reach the application twice. Events may also arrive out of order, since a retried event can arrive after a newer one.
Following the deliveries
The edit page of the application lists, in "Event deliveries", every event sent to its channels:
its status (Pending, Sent, Failed or Canceled), its attempts, and for each attempt the
HTTP status of the answer and the error. A Pending delivery shows when its next attempt is due.
The same list is GET /api/v1/apps/:app_id/event_deliveries, for the owners of the application
and the administrators, newest first. Its query parameters are all optional: channel (a channel
key), status, offset and limit (20 by default, at most 100).
Handling events
- Check the credentials on every request, with a constant-time comparison; answer
401when they do not match. - Validate the body:
eventis a string,idanduser.idare UUIDs,timea date. - Read the current state instead of trusting the event: fetch the user, then apply it. This makes the handler idempotent, and indifferent to events arriving twice or out of order, which both happen.
- Accept unknown users and unknown events with a
2xx: they are not errors. - Keep the sync done at sign-in. A delivery can still fail for good, so events speed up the update but do not replace it.
A minimal handler in Node, with Express. @eternaltwin/core describes the event body
(UserStoreEvent) and @eternaltwin/client-node reads the user back:
yarn add @eternaltwin/client-node @eternaltwin/core
import { timingSafeEqual } from "node:crypto";
import { EternaltwinNodeClient } from "@eternaltwin/client-node";
import { RfcOauthAccessTokenKey } from "@eternaltwin/core/oauth/rfc-oauth-access-token-key";
import { ShortUser } from "@eternaltwin/core/user/short-user";
import { readUserStoreEvent, UserStoreEvent } from "@eternaltwin/core/user/user-store-event";
import { UserStoreEventKind } from "@eternaltwin/core/user/user-store-event-kind";
import express from "express";
const eternaltwin = new EternaltwinNodeClient(new URL("https://eternaltwin.org/"));
const expected = Buffer.from(
"Basic " + Buffer.from(`eternaltwin.${process.env.ETERNALTWIN_CHANNEL}:${process.env.ETERNALTWIN_SECRET}`).toString("base64"),
);
app.post("/api/eternaltwin/events", express.json(), (req, res) => {
const got = Buffer.from(req.get("authorization") ?? "");
if (got.length !== expected.length || !timingSafeEqual(got, expected)) {
return res.sendStatus(401);
}
const event: UserStoreEvent | null = readUserStoreEvent(req.body);
if (event === null) {
// An event kind this version of `@eternaltwin/core` does not know, or an invalid body.
return res.sendStatus(204);
}
res.sendStatus(204);
handleEvent(event).catch(console.error);
});
async function handleEvent(event: UserStoreEvent): Promise<void> {
const userId = event.user.id;
switch (event.event) {
case UserStoreEventKind.UserUpdated: {
const auth: RfcOauthAccessTokenKey | null = await players.getAccessToken(userId);
if (auth === null) {
return;
}
const user: ShortUser = await eternaltwin.getUserById({userId, auth});
return players.setName(userId, user.displayName.current.value);
}
case UserStoreEventKind.UserDisabled:
return players.setDisabled(userId, true);
case UserStoreEventKind.UserEnabled:
return players.setDisabled(userId, false);
}
}
readUserStoreEvent checks the whole body (id and user.id are UUIDs, time a date) and turns
event into a UserStoreEventKind value, so switch covers every kind the package knows. It returns
null for a kind added later, until the package is updated: the handler answers 2xx and drops
it, as step 4 asks. The client takes the root of the Eternaltwin server (http://localhost:50320/
locally) and appends api/v1 itself. players stands for the application's storage, which keeps
the OAuth access token received at sign-in for each user.
Testing locally
- Set the event webhook URL of your application on its edit page, or add
event_stream_urito its seed before the local server first creates it. - Sign in to your application once with a test account (for example
alice), so it holds an access token for it. - Change that account's display name on the local Eternaltwin website: a
UserUpdatedrequest reaches your endpoint. - Follow the requests in "Event deliveries", on the edit page of your application: answer with an error once to see the retry.
- Check the rejection:
curl -u wrong:creds -X POST <url> -H 'content-type: application/json' -d '{}'answers401.
In the source
| Part | Location |
|---|---|
event_stream_uri seed key | crates/config/src/seed/app.rs |
| Which channels receive an event | crates/services/src/app/mod.rs (dispatch_user_event) |
| Replay of the events missed | crates/services/src/app/mod.rs (watch_app_user) |
| Sending job, retries | crates/services/src/app/job/dispatch_app_event.rs, crates/core/src/app/mod.rs (APP_EVENT_RETRY_DELAYS) |
| HTTP request | crates/eternaltwin_app_client/src/http/send_event.rs |
| Event types | crates/core/src/user/store/event/, and packages/core/src/lib/user/user-store-event.mts for TypeScript |
| Rate limit | crates/eternaltwin_app_store/src/pg/acquire_outbound_app_event_request.rs |