uloggdDevelopers
Developer portaluloggd
Get startedResourcesArcade
Developer portaluloggdIdentityCatalogProfileLibraryReviewsJournalListsAwardsScreenshotsSocialAccountNotificationsCommentsLikesHistoryPeopleMineralsArcade

Arcade

The directory, the store pages and the identity keys, and the session-only routes behind the portal and Settings.

Three kinds of route live here. How Arcade works, and how to build a game for it, is in the Arcade guides.

  • Public: the directory, a store page and the identity keys answer anyone, with no key and no session.
  • The developer's, under /api/v1/account/apps/{id}/arcade: a game's store page, releases and development URL; and /api/v1/account/arcade/terms, which accepts the developer terms again after they change. Session only, and the database changes nothing for a session that was not verified with two-factor (403 with reason mfa_required). Submitting a release or the store page, making a release live and removing the game also need a session that signed in within the last 15 minutes: an older one gets 403 forbidden with reason reauthentication_required, and the way through is signing in again.
  • The player's, under /api/v1/account/arcade/grants: the games a player let know who they are. Session only.

A key gets 403 forbidden on every session-only route: in this phase no scope opens a game, or a player's games, to a key.

Every answer is under data, like the rest of the API. The directory adds next, its cursor; the key set is the one exception, a plain JSON Web Key Set that any verifier can read.

GET/api/v1/arcade/apps

The Arcade directory: the listed games, newest approval first, read only from approved store pages. Public: answers without a key or a session. Answers { data, next }; next is the cursor of the following page, or null on the last one.

No scope required · Counted against the read allowance

Query

FieldTypeNotes
qstringSearches by name. Up to 80 characters.
cursorstringThe next of the previous page.
limitinteger1 to 60. The default is 30.

Example response

{
  "data": [
    {
      "slug": "orbit-runner",
      "name": "Orbit Runner",
      "tagline": "Dodge asteroids for as long as you can.",
      "icon_url": "...",
      "tile_url": null,
      "tags": [],
      "age_rating": "L",
      "controller_name": "Orbit Studio",
      "platforms": ["desktop", "mobile"]
    }
  ],
  "next": null
}
GET/api/v1/arcade/apps/{slug}

A game's store page, built only from the approved version: what a player reads before playing, never what the developer is still editing. Public. Beside the directory's fields it carries description (the text per language, with default_locale), cover_url, screenshots, orientation, privacy_url, terms_url, support_url, controller_country, privacy_contact, host (where the game runs, in ASCII), scopes, capabilities and reviewed_at. With no listed game under that slug, 404 not_found with reason app_not_found.

No scope required · Counted against the read allowance

Example response

{
  "data": {
    "slug": "orbit-runner",
    "name": "Orbit Runner",
    "tagline": "Dodge asteroids for as long as you can.",
    "icon_url": "...",
    "tile_url": null,
    "tags": [],
    "age_rating": "L",
    "controller_name": "Orbit Studio",
    "platforms": ["desktop", "mobile"],
    "description": { "default_locale": "en", "en": "..." },
    "cover_url": null,
    "screenshots": [],
    "orientation": "landscape",
    "privacy_url": "https://orbit.example.com/privacy",
    "terms_url": null,
    "support_url": "https://orbit.example.com/support",
    "controller_country": "BR",
    "privacy_contact": "[email protected]",
    "host": "orbit.example.com",
    "scopes": ["identify"],
    "capabilities": [],
    "reviewed_at": "2026-10-20T15:00:00.000Z"
  }
}
GET/api/v1/arcade/jwks

The public keys that verify identity and data event tokens: a JSON Web Key Set, { keys }, with no data around it, holding every key that signs or is about to sign or leave. Public, with Cache-Control: public, max-age=300, stale-while-revalidate=60; keep it for 10 minutes at most. With no keys configured, 503 with reason identity_unavailable, never an empty set. Always fetch it from https://uloggd.com, never from a *.squareweb.app address.

No scope required · Counted against the read allowance

Example response

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "...",
      "y": "...",
      "kid": "arc-2026-10",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}
GET/api/v1/account/arcade/grants

The Arcade games the account let know who it is, for Settings > Arcade: app_id, slug, name, icon_url, scopes, nickname, granted_at and revoked_at (set after Revoke). Session only: a key gets 403 forbidden.

No scope required · Counted against the read allowance

Example response

{
  "data": [
    {
      "app_id": "...",
      "slug": "orbit-runner",
      "name": "Orbit Runner",
      "icon_url": "...",
      "scopes": ["identify"],
      "nickname": "Brave Comet 42",
      "granted_at": "2026-10-21T18:00:00.000Z",
      "revoked_at": null
    }
  ]
}
PATCH/api/v1/account/arcade/grants/{appId}

Changes the nickname a game knows. Answers { data: { grant } }, and the game's server gets a nickname_changed data event. Up to 10 changes a day per account. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
nicknamerequiredstring2 to 32 characters, with no space at either end and no control, bidirectional or zero-width characters. Otherwise 400 with reason nickname_invalid.
DELETE/api/v1/account/arcade/grants/{appId}

Revoke: the game stops getting tokens at once, an open game hears it through the GRANT_UPDATE event, and its server gets a grant_revoked data event. The player's id in that game stays the same if they agree again. With ?forget=true it is Forget this game: the event is grant_forgotten, and next time the game gets a new id. Answers { data: null }. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

Query

FieldTypeNotes
forgetbooleantrue to forget rather than revoke.
GET/api/v1/account/apps/{id}/arcade

An app's place on Arcade: { data: { eligibility, app } }. eligibility says what stands between the account and publishing (allowlisted, two_factor, mfa_session, email_verified, account_age_days, adult, terms with current and accepted, banned, open_publishing, blocked, arcade_apps, and eligible when nothing does). open_publishing says whether an account that was not invited may publish; blocked, whether moderation blocked the account from publishing; arcade_apps carries used, the account's Arcade games that were not removed, and limit, how many an account that was not invited may hold (null for an invited account). When terms.accepted is older than terms.current, the account accepts again at POST /api/v1/account/arcade/terms. app is null until Arcade is enabled; then it carries slug, status (draft, listed, hidden, suspended or removed), the store page (listing with approved, pending, draft and rejected: the newest refused revision, when it is newer than the approved one, with the reviewer's reasons and note in review, or null), releases, data_events (pending, every event neither delivered nor failed; failed; oldest_pending_at; and held, how many of those pending ones wait for a release with an events URL), events, the game's timeline, without who acted, and notice: null, or what the developer has to know now, with kind (suspended, flagged or hidden), reason and at. For a suspension or a flag, reason is what moderation wrote for the developer (null when it wrote nothing); for a hidden game, invariant (two checks failed), provider_change or reports (reports took it down until moderation decides). A flagged game keeps status listed but nobody can open it: notice, not status, says whether it is on the air. Session only: a key gets 403 forbidden.

No scope required · Counted against the read allowance

POST/api/v1/account/apps/{id}/arcade

Enables Arcade on the app: reserves the slug and records the terms accepted. 201 with { data: { app } }. The database checks every condition and says which one failed: 403 with reason not_allowlisted (only while open publishing is off), two_factor_required, mfa_required, email_unverified, account_too_new, adult_required, terms_required (field terms_version), banned or publisher_blocked; 400 with slug_invalid or slug_reserved; 409 with slug_taken, already_enabled or arcade_app_limit (an account that was not invited already holds as many games as eligibility.arcade_apps.limit). Up to 5 a day per account. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
slugrequiredstring3 to 40 characters of a-z, 0-9 and hyphens, with no hyphen at either end. It is the store page's address, and it is never reused.
terms_versionrequiredstringThe version of the Arcade developer terms the person read and accepted: eligibility.terms.current. The server records it with the hash of the text it shows, so a version other than its own gets 403 terms_required with field terms_version before reaching the database, even when the database already calls it current (while uloggd is being updated); try again shortly.
POST/api/v1/account/arcade/terms

Accepts the Arcade developer terms again, after they changed: without it, submitting store pages and releases and making a release live get 403 with reason terms_required. The server records the version accepted with the hash of the text it shows; the body only names the version. Answers { data: { terms } }, with current and accepted, as eligibility.terms. Needs a session that passed two-factor verification. The refusals: 403 with terms_required (field terms_version) when the version is not the current one, mfa_required or banned; 400 with fields_invalid for a body with any other field. Up to 10 a day per account, then 429 rate_limited with retry_after. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
terms_versionrequiredstringThe version the person read and accepted: eligibility.terms.current.

Example response

{
  "data": {
    "terms": {
      "current": "2026-10-13",
      "accepted": "2026-10-13"
    }
  }
}
PUT/api/v1/account/apps/{id}/arcade/listing

Creates or updates the store page's single draft, with every field. Answers { data: { listing } }. Players keep seeing the approved version until a moderator approves the next. A submitted draft no longer changes: 409 with reason listing_locked. A refused field is 400 with reason fields_invalid and field, the name of the first field refused; a reserved word in the name or the tagline comes as field name_reserved or tagline_reserved. Session only: a key gets 403 forbidden. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
namerequiredstring2 to 40 characters, without reserved words such as uloggd, official or support.
taglinerequiredstringUp to 80 characters.
descriptionrequiredobjectdefault_locale (pt-BR, en or es) and the text in pt-BR, en and es, up to 2000 characters each; the default language cannot be empty.
tagsrequiredarrayUp to 5, from this fixed list: action, adventure, arcade, board, card, casual, educational, fighting, horror, idle, music, platformer, puzzle, racing, retro, rhythm, roguelike, rpg, sandbox, shooter, simulation, sports, strategy, tower-defense, trivia, visual-novel, word.
age_ratingrequiredstringThe ClassInd self-rating: L, 10, 12, 14 or 16. A higher one takes effect on submission; a lower one waits for review.
orientationrequiredstringany, landscape or portrait.
platformsrequiredarraydesktop, mobile or both; at least one.
privacy_urlrequiredstringAn https URL.
terms_urlrequiredstringAn https URL, or null.
support_urlrequiredstringAn https URL.
controller_namerequiredstringWho answers for the players' data, 2 to 120 characters.
controller_countryrequiredstringAn ISO 3166-1 alpha-2 code, such as BR. Outside Brazil, the consent says the data will be processed outside the country.
privacy_contactrequiredstringAn email address or an https URL, up to 254 characters.
POST/api/v1/account/apps/{id}/arcade/listing/submit

Submits the store page's draft for review. Answers { data: { listing } } and emails the developer. Up to 5 submissions a day per app. Session only: a key gets 403 forbidden. It also needs a session that signed in within the last 15 minutes: an older one gets 403 forbidden with reason reauthentication_required, and the way through is signing in again. An account moderation blocked from publishing gets 403 with reason publisher_blocked. It also needs the current version of the developer terms accepted: after they change, 403 with reason terms_required until the account accepts again at POST /api/v1/account/arcade/terms.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/media

Puts an image in a slot of the store page's draft, as multipart/form-data with kind, position and file (JPEG, PNG, WebP, GIF or AVIF, up to 15 MB). icon and tile are treated as avatars, square and possibly animated; cover and screenshot as layout images, still only. Every image goes through the content check. 201 with { data: { kind, position, image_url } }; a refused image is 400 with reason media_invalid and detail. Up to 30 uploads a day per app. Session only: a key gets 403 forbidden. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
kindrequiredstringicon, tile, cover or screenshot.
positionrequiredinteger0 for icon, tile and cover; 0 to 5 for screenshot.
filerequiredfileThe image.
DELETE/api/v1/account/apps/{id}/arcade/media

Empties a slot of the store page's draft. Answers { data: { kind, position, image_url: null } }. Session only: a key gets 403 forbidden. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

Query

FieldTypeNotes
kindrequiredstringicon, tile, cover or screenshot.
positionrequiredinteger0 for icon, tile and cover; 0 to 5 for screenshot.
GET/api/v1/account/apps/{id}/arcade/releases

The app's releases, in { data }. Each carries the POST's fields, number, status (draft, submitted, in_review, approved, rejected, withdrawn or revoked), entry_origin, the dates, review (the decision, the reasons and the reviewer's note, without the name), last_check, the latest check, and live, previous_live, can_make_live and can_test. Session only: a key gets 403 forbidden.

No scope required · Counted against the read allowance

POST/api/v1/account/apps/{id}/arcade/releases

Creates a draft release. 201 with { data: { release } }. A refused field is 400 with reason fields_invalid and field, the name of the first field refused; asking for identify without a data_events_url is 400 with data_events_url_required. Session only: a key gets 403 forbidden. A url_denied about an internationalized name carries detail, the why (idn while Arcade does not take those names; idn_invalid, idn_script or idn_confusable when the rules refuse the name), and field, the URL it is about: entry_url, data_events_url or third_parties.N.origin. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
entry_urlrequiredstringAn https URL that passes Arcade's URL rules (up to 512 characters, port 443, no query or fragment); otherwise 400 with reason url_invalid or url_denied.
capabilitiesrequiredarraypointer-lock, background-audio, both or neither.
scopesrequiredarrayidentify, or empty.
orientationrequiredstringany, landscape or portrait.
platformsrequiredarraydesktop, mobile or both; at least one.
third_partiesrequiredarrayEvery third party the game loads: up to 20 { origin, purpose }, with a purpose of up to 200 characters.
data_events_urlrequiredstringWhere data events go, or null. Required with identify, and under the same URL rules, except that the origin and the path may differ.
notesrequiredstringNotes for the reviewer, up to 2000 characters.
PUT/api/v1/account/apps/{id}/arcade/releases/{rid}

Replaces a draft release, with the POST's fields. Answers { data: { release } }. A submitted release never changes: 409 with reason release_locked, and a new URL is a new release. Session only: a key gets 403 forbidden. A url_denied about an internationalized name carries detail, the why (idn while Arcade does not take those names; idn_invalid, idn_script or idn_confusable when the rules refuse the name), and field, the URL it is about: entry_url, data_events_url or third_parties.N.origin. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/releases/{rid}/check

Check URL: fetches the proof file and the entry URL now, from uloggd's servers, and answers { data: { check } } with what it saw: passed, proof_ok, redirect_ok, framing_ok, status_code, location, content_type, entry_sha256, changed_since_approval, provider, network, signals and error. Up to 20 checks a day per app, the ones run on submitting and on making live included. Session only: a key gets 403 forbidden. An account moderation blocked from publishing gets 403 with reason publisher_blocked.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/releases/{rid}/submit

Submit for review: runs a fresh check and, when it passes, submits the release and claims its origin for the app. Answers { data: { release, check } } and emails the developer. A failed check is 409 with reason check_failed; an origin another app holds, 409 with origin_in_use. Up to 5 submissions a day per app. Session only: a key gets 403 forbidden. It also needs a session that signed in within the last 15 minutes: an older one gets 403 forbidden with reason reauthentication_required, and the way through is signing in again. An account moderation blocked from publishing gets 403 with reason publisher_blocked. It also needs the current version of the developer terms accepted: after they change, 403 with reason terms_required until the account accepts again at POST /api/v1/account/arcade/terms. A url_denied about an internationalized name carries detail, the why (idn while Arcade does not take those names; idn_invalid, idn_script or idn_confusable when the rules refuse the name), and field, the URL it is about: entry_url, data_events_url or third_parties.N.origin. The host also goes through what moderation decided about networks and domains: 400 with reason url_denied, detail network and field entry_url when the host's network or registrar is blocked, and 409 with domain_too_new when the domain was registered too recently for Arcade.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/releases/{rid}/withdraw

Withdraw: takes a submitted release out of the review queue. Answers { data: { release } }. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/releases/{rid}/live

Make live: puts an approved release in front of players, or brings back the one that was live before. It runs a fresh check first, and answers { data: { app, check } } with an email to the developer. The refusals, in 409: release_not_approved, check_failed, approval_too_old (the approval is over 90 days old), provider_changed (the hosting provider is not the one at approval) and rollback_too_far (only the release live just before can come back; an older one is resubmitted as a new release). Session only: a key gets 403 forbidden. It also needs a session that signed in within the last 15 minutes: an older one gets 403 forbidden with reason reauthentication_required, and the way through is signing in again. An account moderation blocked from publishing gets 403 with reason publisher_blocked. It also needs the current version of the developer terms accepted: after they change, 403 with reason terms_required until the account accepts again at POST /api/v1/account/arcade/terms. A url_denied about an internationalized name carries detail, the why (idn while Arcade does not take those names; idn_invalid, idn_script or idn_confusable when the rules refuse the name), and field, the URL it is about: entry_url, data_events_url or third_parties.N.origin. The host also goes through what moderation decided about networks and domains: 400 with reason url_denied, detail network and field entry_url when the host's network or registrar is blocked, and 409 with domain_too_new when the domain was registered too recently for Arcade.

No scope required · Counted against the write allowance

GET/api/v1/account/apps/{id}/arcade/override

The app's development URL: { data: { override } }, null when none was saved. override carries url, origin, host (the origin without its scheme, port kept), loopback (whether it is the owner's own machine), release_id and release_number (the base release, which gives the capabilities, scopes, orientation and platforms), set_at, expires_at, expired, ready (it opens now: not expired and nothing blocks it), blocked_reason (what blocks it, in the refusal vocabulary: override_expired, not_playable, release_not_found, listing_not_found, origin_in_use or url_denied; null when nothing does) and changes_left (what is left of the day's 20 saves). Its owner only, on a session that passed its second factor (the URL is often a public tunnel to the owner's machine): 403 with reason not_owner for another account, 403 with mfa_required for a session that still owes its code, 404 with app_not_found for an app without Arcade. Session only: a key gets 403 forbidden.

No scope required · Counted against the read allowance

Example response

{
  "data": {
    "override": {
      "url": "http://localhost:5173/",
      "origin": "http://localhost:5173",
      "host": "localhost:5173",
      "loopback": true,
      "release_id": "...",
      "release_number": 2,
      "set_at": "2026-10-14T18:00:00.000Z",
      "expires_at": "2026-10-15T18:00:00.000Z",
      "expired": false,
      "ready": true,
      "blocked_reason": null,
      "changes_left": 19
    }
  }
}
PUT/api/v1/account/apps/{id}/arcade/override

Saves the development URL for 24 hours, which only the owner opens in the player, with what the base release asks for; saving again, with the same URL or another, renews the 24 hours. Answers { data: { override } }. Needs a session that passed two-factor verification. Nothing is fetched from the URL; a public host has to resolve to public addresses only. The refusals: 400 with reason url_invalid or url_denied (field url) or fields_invalid (with field); 403 with not_owner, mfa_required, banned, publisher_blocked or not_playable; 404 with app_not_found or release_not_found; 409 with origin_in_use (the origin belongs to another account's game). Up to 20 saves a day per app, then 429 rate_limited with retry_after; only a save that is kept counts, and a refusal, a host that does not resolve to public addresses included, spends none. Looking a public host up has a limit of its own: up to 60 an hour per account, counted before the lookup, even when the lookup or the save is refused, then 429 rate_limited with retry_after; the owner's own machine is not looked up and does not count. A game open on the previous URL closes within 30 seconds when the new one is on another origin; on the same origin it keeps the path and base release it opened with until it is opened again. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

Body

FieldTypeNotes
urlrequiredstringUp to 512 characters, with no query or fragment. http or https on the owner's machine (localhost, a name under .localhost or 127.0.0.1, on any port), or https on a public host, on any port. Never an IP address other than 127.0.0.1, nor the host uloggd was opened on.
release_idrequireduuidThe base release, of the same app: a draft, submitted, in review or approved.
DELETE/api/v1/account/apps/{id}/arcade/override

Clears the development URL: a game open on it is closed within 30 seconds. Answers { data: { override: null } }, also when there was nothing to clear. It does not count against the day's limit. 403 with not_owner, mfa_required or banned; 404 with app_not_found. Session only: a key gets 403 forbidden.

No scope required · Counted against the write allowance

POST/api/v1/account/apps/{id}/arcade/remove

Remove from Arcade: the game leaves for good, the slug and the origin are never reused, and the evidence is kept for 180 days. Answers { data: { app } } and emails the developer. Session only: a key gets 403 forbidden. It also needs a session that signed in within the last 15 minutes: an older one gets 403 forbidden with reason reauthentication_required, and the way through is signing in again.

No scope required · Counted against the write allowance

The development URL

GET, PUT and DELETE /api/v1/account/apps/{id}/arcade/override read, save and clear an app's development URL, the address its owner opens in uloggd's player while the game runs on their own machine or behind a tunnel. They are session only, and all three need a session verified with two-factor, reading included: the URL is often a tunnel into the developer's machine.

Opening it is a launch of kind override, for the app's owner alone, in mode test, at /developers/applications/{id}/arcade/override/play. That launch is refused with override_not_found when no URL is saved, and with override_expired once its 24 hours are over. Saving again renews them.

Refusals

An Arcade refusal carries a reason, one word to act on, beside the usual code (the other members a refusal can carry are in Errors):

{
  "error": {
    "code": "conflict",
    "message": "Another app already uses that origin.",
    "reason": "origin_in_use"
  }
}
Statuscodereason on these routes
403forbiddennot_owner, not_allowlisted, two_factor_required, mfa_required, email_unverified, account_too_new, adult_required, terms_required, banned, publisher_blocked, grant_required, reauthentication_required, not_playable
404not_foundapp_not_found, release_not_found, listing_not_found, check_not_found, override_not_found
400invalid_requestslug_invalid, slug_reserved, url_invalid, url_denied (with field and detail when it says why, as for an internationalized name), data_events_url_required, nickname_invalid, fields_invalid (with field, one field's name, as on the URL ones), media_invalid (with detail)
409conflictslug_taken, already_enabled, origin_in_use, release_locked, listing_locked, release_not_approved, check_required, check_failed, check_stale, approval_too_old, provider_changed, rollback_too_far, remove_from_arcade_first, override_expired, arcade_app_limit, domain_too_new
429rate_limitedrate_limited, with retry_after in seconds
503unavailableidentity_unavailable, from the key set while no key is configured

New reasons can appear. A client that meets one it does not know should act on the code.

Limits

WhatLimit
Enabling Arcade5 per account per day
URL checks20 per app per day, the ones run on submitting and on making live included
Release submissions, store page submissions5 each per app per day
Image uploads30 per app per day
Nickname changes10 per account per day
Development URL saves20 per app per day; clearing it is free
Accepting the developer terms again10 per account per day
Lookups of the development URL's public host60 per account per hour, refused ones included

Minerals

The wallet and what passes through it.