MarvinMC MarvinMC Hosting

OAuth2 sign-in

Integrate MarvinMC Hosting accounts using the Authorization Code flow with Discord-style identify and email scopes.

Create application

Migrating a Discord-style login

Register a Hosting application, then replace the authorization, token and user endpoints and use its Hosting client ID and secret. Discord credentials do not work here. Register the same callback URL in the Developer Portal. The identify/email code flow and user response follow Discord conventions; bot, guild and other Discord APIs are not emulated. IDs identify Hosting accounts, not Discord accounts.

Endpoints

GET
https://hosting.marvinmc.net/oauth2/authorize
POST
https://hosting.marvinmc.net/api/oauth2/token
GET
https://hosting.marvinmc.net/api/users/@me
POST
https://hosting.marvinmc.net/api/oauth2/token/revoke

The API endpoints also accept /api/v6 through /api/v10, for example /api/v10/users/@me. Existing /oauth/authorize, /oauth/token, /oauth/userinfo and /oauth/revoke URLs remain available. The legacy userinfo response retains sub and email_verified.

Authorization server metadata

Scopes

identify
Identify your account by its user ID and username.
email
Read your email address and whether it is verified.
connections.read
Read the ID and username of your linked Discord account. No Discord tokens are shared.
customer.read
Read whether your account is a paid customer. No amounts or payments are shared.
server.read
Read the selected server, its resources and live statistics.
server.start
Start the selected server.
server.stop
Stop the selected server.
server.restart
Restart the selected server.

identify is required and is the default when scope is omitted. Add email only when needed. The previous profile scope remains an alias of identify.

Server authorisation and hosting API

Enable the required scopes in the Developer Portal and include them in scope, for example identify server.read server.restart. The owner explicitly chooses one eligible server on the consent screen. Each token and its refresh tokens remain bound to that installation. Authorise again for another server and keep its tokens separately. Server scopes always require interactive consent; prompt=none returns consent_required.

For server scopes, the token response also contains server_id, the ID explicitly selected during consent. Store it with that token. Refresh preserves this binding. Identity-only responses remain unchanged.

Bearer tokens work on the customer API endpoints below. The server type must allow API access. Captcha requirements cannot be bypassed. Under Server > Permissions, owners can disable individual actions or remove the app. A new authorisation cannot revive tokens from a removed installation. Account-wide revocation also removes all server installations. Password changes, client changes and secret rotation invalidate tokens. These scopes do not permit purchases, file access, console commands or killing processes.

GET /api/client/v1/connections
connections.read
GET /api/client/v1/customer
customer.read
GET /api/client/v1/servers
server.read
GET /api/client/v1/servers/{id}
server.read
GET /api/client/v1/servers/{id}/stats
server.read
POST /api/client/v1/servers/{id}/power
server.start | server.stop | server.restart
GET /api/client/v1/jobs/{id}
Same app, server installation and action permission
curl https://hosting.marvinmc.net/api/client/v1/servers/SERVER_ID/power \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: UNIQUE_REQUEST_ID' \
  --data '{"action":"restart"}'

Power requests return HTTP 202 with a job; poll its job URL using a valid token for the same app and server installation with the action scope. Queued actions recheck the original token and current permissions before execution. Expired or revoked tokens cannot execute jobs. GET servers returns only the selected authorised server; stats returns live status and resource usage. connections returns only public Discord identity; customer returns paid_customer as a boolean. Errors: 401 invalid token, 403 insufficient permissions, 409 busy, 429 rate limit (60 requests per minute per app and user). Respect Retry-After. Responses are not cacheable.

1. Register your application

Create an application in the Developer Portal. Under General information you can add its name, icon, description and public links. Under OAuth2, register the exact callback URL and enable email access only if needed. Store the client secret exclusively on your backend, never in browser or mobile code. If you lose it, choose Regenerate secret under OAuth2: the application and client ID stay the same, and the new secret is displayed once. Replace the old secret in your integration; previous codes and tokens become invalid. Native apps use the public type without a secret. The URL generator can prepare a PKCE test request.

2. Redirect the user

Redirect to the authorization endpoint with response_type=code, client_id, the registered redirect_uri and scope=identify email. State is optional and returned unchanged if supplied (up to 2048 bytes, no control characters). A fresh random state bound to the application session is strongly recommended for CSRF protection. PKCE S256 is optional for backends with a client secret and required for public clients. The example below includes both protections: store state and verifier in your session; the verifier has 43–128 characters and the challenge is base64url(SHA-256(verifier)) without padding.

$state = bin2hex(random_bytes(32));
$verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$_SESSION['marvinmc_oauth'] = ['state' => $state, 'verifier' => $verifier];
$url = 'https://hosting.marvinmc.net/oauth2/authorize?' . http_build_query([
    'response_type' => 'code',
    'client_id' => 'YOUR_CLIENT_ID',
    'redirect_uri' => 'https://example.com/auth/marvinmc/callback',
    'scope' => 'identify email',
    'state' => $state,
    'code_challenge' => $challenge,
    'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
header('Location: ' . $url);
exit;

3. Validate the callback

If you sent state, compare the returned value with your session value in constant time and reject a missing or mismatched value. Validate the returned issuer iss against https://hosting.marvinmc.net. Your integration must protect its callback against CSRF even when omitting state; the Hosting consent form does not replace that protection. Handle error=access_denied without signing in. Exchange the code once, within 5 minutes, with the exact original redirect_uri. Send code_verifier only if a challenge was used.

4. Exchange the code

POST application/x-www-form-urlencoded, not JSON. Backend clients authenticate with HTTP Basic (URL-encode ID and secret before Base64 encoding) or client_id and client_secret in the body, never both. Public clients send client_id without a secret. Omit code_verifier when no PKCE challenge was sent. The response contains access_token, token_type=Bearer, expires_in, refresh_token and scope. Access tokens currently last 900 seconds; use expires_in rather than assuming Discord token lifetimes.

curl https://hosting.marvinmc.net/api/oauth2/token \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
  --data-urlencode 'code=CALLBACK_CODE' \
  --data-urlencode 'redirect_uri=https://example.com/auth/marvinmc/callback' \
  --data-urlencode 'code_verifier=SESSION_VERIFIER'

5. Retrieve the user profile

Call /api/users/@me with Authorization: Bearer. Use id as the stable Hosting account key. identify returns id, username, discriminator="0", global_name and avatar=null. email and verified are included only with the email scope. A linked Discord identity is available separately with connections.read. Hosting actions require separate scopes and explicit server authorisation; spending balance is not supported.

curl https://hosting.marvinmc.net/api/users/@me \
  -H 'Authorization: Bearer ACCESS_TOKEN'

{
  "id": "123456789012345678",
  "username": "example",
  "discriminator": "0",
  "global_name": "example",
  "avatar": null,
  "email": "example@example.com",
  "verified": true
}

6. Refresh tokens

Send grant_type=refresh_token, refresh_token and the same client authentication to the token endpoint. Each refresh returns a new access token and refresh token; store both atomically. The previous tokens stop working. Refresh tokens expire 30 days after issuance. Refresh requests for one token must be serialized: reusing an old refresh token revokes its token family and requires a new login. An optional scope can reduce permissions but cannot add them. No PKCE verifier is needed for refresh requests.

curl https://hosting.marvinmc.net/api/oauth2/token \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
  --data-urlencode 'refresh_token=REFRESH_TOKEN'

Revocation and errors

POST token (access or refresh token) and client authentication to /api/oauth2/token/revoke. This revokes the user authorization for that application, including its codes, access and refresh tokens. Unknown or already revoked tokens also return HTTP 200. Password changes, forced password changes, client edits, pausing and secret replacement invalidate affected tokens. Reusing a code revokes its token family. Handle invalid_request, invalid_client, invalid_grant, invalid_scope, invalid_token and HTTP 429; respect Retry-After.

curl https://hosting.marvinmc.net/api/oauth2/token/revoke \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
  --data-urlencode 'token=ACCESS_OR_REFRESH_TOKEN'

Protocol and limits

OAuth2 only: no OpenID Connect, ID tokens, password, implicit or client-credentials grants. Token, profile and revocation requests use a backend or native client; browser CORS is not enabled. Callbacks must match exactly. PKCE, when used, must be S256; public clients cannot omit it. Normal requests and prompt=consent show consent. prompt=none only succeeds for a logged-in, verified user with an unchanged, previously approved application and permissions; otherwise the callback receives login_required or consent_required. Normal passkey/2FA protections remain in place.