Eternaltwin

Home | Applications

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 POST per event, to a URL the application provides (a webhook). There is no SSE or websocket stream.
  • Only user events exist today (the User topic).
  • 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 authValue
Logineternaltwin.<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.
PasswordThe 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"
  }
}
eventSent when
UserUpdatedSomething 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.
UserDisabledThe account was disabled.
UserEnabledThe 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 2xx status 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

  1. Check the credentials on every request, with a constant-time comparison; answer 401 when they do not match.
  2. Validate the body: event is a string, id and user.id are UUIDs, time a date.
  3. 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.
  4. Accept unknown users and unknown events with a 2xx: they are not errors.
  5. 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

  1. Set the event webhook URL of your application on its edit page, or add event_stream_uri to its seed before the local server first creates it.
  2. Sign in to your application once with a test account (for example alice), so it holds an access token for it.
  3. Change that account's display name on the local Eternaltwin website: a UserUpdated request reaches your endpoint.
  4. Follow the requests in "Event deliveries", on the edit page of your application: answer with an error once to see the retry.
  5. Check the rejection: curl -u wrong:creds -X POST <url> -H 'content-type: application/json' -d '{}' answers 401.

In the source

PartLocation
event_stream_uri seed keycrates/config/src/seed/app.rs
Which channels receive an eventcrates/services/src/app/mod.rs (dispatch_user_event)
Replay of the events missedcrates/services/src/app/mod.rs (watch_app_user)
Sending job, retriescrates/services/src/app/job/dispatch_app_event.rs, crates/core/src/app/mod.rs (APP_EVENT_RETRY_DELAYS)
HTTP requestcrates/eternaltwin_app_client/src/http/send_event.rs
Event typescrates/core/src/user/store/event/, and packages/core/src/lib/user/user-store-event.mts for TypeScript
Rate limitcrates/eternaltwin_app_store/src/pg/acquire_outbound_app_event_request.rs