Aller au contenu principal

Discord

Legacy platform

This page documents the legacy v1 platform (Laravel 8). It is kept for reference and for plugins and bots built against it. For the current platform, see Discord (v2).

These endpoints were called by Discord bots and by the "link your Discord account" button of the dashboard: linking a Discord account, reading which resources a Discord member owns, granting a resource, and reading the public information of a Discord server.

All of them live under https://groupez.dev/api/v1/discord.

Overview​

MethodURIRoute namePurpose
GET/api/v1/discord/authenticationapi.v1.discord.authOAuth callback that links a Discord account
POST/api/v1/discordapi.v1.discordResources owned by a Discord member
POST/api/v1/discord/{user}api.v1.discord.idA Discord member and the resources they own
POST/api/v1/discord/{user}/apply/{plugin}api.v1.discord.resourceGrants a resource to a Discord member (shared secret)
GET/api/v1/discord/{serverId}api.v1.discord.infoPublic information of a Discord server

Common behaviour​

Middleware and rate limit​

The routes are mounted with the api middleware group: throttle:api, implicit model binding and Cloudflare proxy trust. There is no session and no CSRF check.

The api limiter allows 60 requests per minute, keyed by user id or, for anonymous callers (always the case here), by IP address. Above the limit the framework answers 429 Too Many Requests; with Accept: application/json the body is:

{
"message": "Too Many Attempts."
}

Content type and errors​

Not always JSON
  • POST /discord, POST /discord/{user} and POST /discord/{user}/apply/{plugin} return a string built with json_encode(). Laravel serves a returned string as text/html; charset=UTF-8, so the body is JSON but the Content-Type header is not application/json. Parse the body regardless of the header.
  • GET /discord/{serverId} returns an array and is served as application/json.
  • The legacy exception handler is the framework default: errors (400, 403, 404, 422, 429) are rendered as JSON only when the request sends Accept: application/json. Otherwise they are HTML error pages (and a validation error becomes a redirect). Always send Accept: application/json.

With Accept: application/json and APP_DEBUG off, an error raised by abort($code, $message) renders as {"message": "<message>"}; a 404 from a failed model binding renders as {"message": "No query results for model [App\\Models\\<Model>] <value>"}.

Route declaration order​

Declaration order matters

GET /discord/authentication is declared before GET /discord/{serverId}; otherwise the word authentication would be read as a guild id. POST /discord/{user} and GET /discord/{serverId} share a URI shape and are told apart only by the verb.

Accesses payload​

POST /discord and POST /discord/{user} return raw rows of the plugin_accesses table, read through the query builder:

KeyTypeDescription
idintegerAccess id
user_idintegergroupez.dev user id
plugin_idintegerResource id
payment_idinteger | nullPayment id, null when the access was not bought
created_atstring | nullRaw database value, e.g. 2021-10-15 12:00:00
updated_atstring | nullRaw database value

No explicit ordering is applied (database order).

Integer or string

The rows come straight from PDO. Whether id, user_id, plugin_id and payment_id are serialized as JSON numbers or as numeric strings depends on the PHP version and PDO settings of the server (PHP 8.1+ with pdo_mysql returns native integers, earlier versions returned strings). This could not be checked against production; accept both.


GET /api/v1/discord/authentication​

OAuth2 redirect URI of the "link your Discord account" flow. Discord redirects the browser here with code and state; the endpoint exchanges the code for a token, reads the Discord profile and stores the link in discord_users, then redirects to the account page of the dashboard.

Route nameAuthRate limit
api.v1.discord.authNone (the groupez.dev user is taken from state)api (60/min)

Path parameters: none.

Query parameters:

NameTypeRequiredDescription
codestringYesOAuth2 authorization code issued by Discord
stateintegerYesgroupez.dev user id the Discord account is linked to

The authorization URL that leads here is built by User::getDiscordAuthLink(): https://discord.com/api/oauth2/authorize?client_id=...&redirect_uri=<this endpoint>&response_type=code&scope=identify&state=<user id>.

Responses​

Every outcome is a 302 redirect to https://groupez.dev/dashboard/account with a flashed message (in French):

CaseFlashed message
Both code and state missingerror: "Une erreur est survenue lors de la liaison de votre compte discord. (Url format)"
state is not an existing user iderror: "... (User id)"
The token exchange with Discord failederror: "... (OAuth2)"
Reading the Discord profile failederror: "... (Api URL)"
This Discord account is already linkederror: "Votre compte est déjà relié."
Successsuccess: "Vous venez d'ajouter votre compte discord sur Serveur Minecraft Vote."

Example​

curl -i "https://groupez.dev/api/v1/discord/authentication?code=<code>&state=7"

Notes​

  • Meant to be reached by a browser coming back from Discord, not called by a bot.
  • The check is missing('code') && missing('state'): only a request missing both is rejected as malformed. A request with only one of them goes on and fails at a later step.
  • The groupez.dev account is identified by state alone, which is the plain user id. The request is not tied to the logged-in session.
  • The flashed messages are written from the api route group, which has no StartSession middleware: the redirect flashes them into a session store that is never started nor saved, so read from the code they are most likely lost and never shown on the dashboard. This was not observed in production.
  • On success, a user log entry ("Création de la liaison discord pour ...") is also written.
  • On the current platform this address only redirects to the standard Discord sign-in flow (see v2).

POST /api/v1/discord​

Intended to return the accesses of a Discord member identified by the discord body field.

Route nameAuthRate limit
api.v1.discordNoneapi (60/min)

Path parameters: none.

Body parameters:

NameTypeRequiredDescription
discordstringYesDiscord user id (snowflake)

Responses​

200 OK — a bare array of accesses:

[
{
"id": 42,
"user_id": 7,
"plugin_id": 12,
"payment_id": 1534,
"created_at": "2021-10-15 12:00:00",
"updated_at": "2021-10-15 12:00:00"
}
]

400 Bad Request — the discord field is absent:

{
"message": "Discord not found"
}

404 Not Found — no user has that Discord id:

{
"message": "User not found"
}

Example​

curl -X POST https://groupez.dev/api/v1/discord \
-H "Accept: application/json" \
-d "discord=123456789012345678"

Notes​

Probably broken on the legacy platform

The controller looks the member up in the users.discord column, but the migration 2021_10_10_203050_remove_discord_in_user dropped that column when Discord links moved to the discord_users table. Read from the code, any request that passes the 400 check runs a query on a column that no longer exists, which would answer 500 Server Error rather than 200 or 404. This could not be verified against the production database.

  • The check is has('discord'): an empty value is not rejected by the 400 (empty strings are converted to null by the global middleware).
  • Revoked accesses do not exist on the legacy platform: every row of plugin_accesses for the user is returned.

POST /api/v1/discord/{user}​

Returns a Discord member (name, groupez.dev id, avatar) with all their accesses.

Route nameAuthRate limit
api.v1.discord.idNoneapi (60/min)

Path parameters:

NameTypeDescription
userstringDiscord user id (snowflake), bound to DiscordUser by its discord_id column ({user:discord_id})

Body parameters: none.

Responses​

200 OK

{
"user": {
"name": "Bobby",
"id": 7,
"avatar": "https://groupez.dev/storage/images/users/0/0/0/7.png"
},
"plugins": [
{
"id": 42,
"user_id": 7,
"plugin_id": 12,
"payment_id": 1534,
"created_at": "2021-10-15 12:00:00",
"updated_at": "2021-10-15 12:00:00"
}
]
}
KeyTypeDescription
user.namestringgroupez.dev display name
user.idintegergroupez.dev user id
user.avatarstringAlways a URL: the uploaded avatar (/storage/images/users/...) or https://groupez.dev/images/users/default_user.png
pluginsarrayAll accesses of the user

404 Not Found — no discord_users row has that discord_id. With Accept: application/json, the body is the framework's model-not-found message:

{
"message": "No query results for model [App\\Models\\DiscordUser] 123456789012345678"
}

Example​

curl -X POST https://groupez.dev/api/v1/discord/123456789012345678 \
-H "Accept: application/json"

Notes​

  • Served as text/html (see Content type and errors).
  • The 404 message names the internal model class and echoes the requested id; rely on the status code.

POST /api/v1/discord/{user}/apply/{plugin}​

Grants a resource to a Discord member, protected by a shared secret.

Route nameAuthRate limit
api.v1.discord.resourceShared secret in the body (custom_password)api (60/min)

Path parameters:

NameTypeDescription
userstringDiscord user id (snowflake), bound to DiscordUser by discord_id
pluginintegerResource id, bound to Plugin by primary key. No status filter: any existing resource, published or not, resolves

Body parameters:

NameTypeRequiredDescription
custom_passwordstringYesShared secret, compared to the DISCORD_PURCHASE_CONFIRM environment variable

Responses​

200 OK — the access was created:

{
"message": "ok"
}

200 OK — the user already had this resource:

{
"message": "ko"
}

403 Forbidden — custom_password does not match:

{
"message": ""
}

404 Not Found — unknown Discord id or unknown resource (model binding). The {user} binding is resolved first, so an unknown Discord id names DiscordUser; with a known member and an unknown resource id the body names Plugin:

{
"message": "No query results for model [App\\Models\\Plugin] 12"
}

422 Unprocessable Entity — custom_password missing (only with Accept: application/json; otherwise a 302 redirect back):

{
"message": "The given data was invalid.",
"errors": {
"custom_password": [
"The custom password field is required."
]
}
}

Example​

curl -X POST https://groupez.dev/api/v1/discord/123456789012345678/apply/12 \
-H "Accept: application/json" \
-d "custom_password=<shared secret>"

Notes​

  • Evaluation order: model bindings (404), then validation (422), then the secret (403), then the grant.
  • The secret is compared with !== against env('DISCORD_PURCHASE_CONFIRM'), read directly from the environment. If the configuration is cached (config:cache), env() returns null outside config files and every call is refused with 403.
  • The created row has no payment_id.
  • Nothing is logged for a grant.
  • Served as text/html (see Content type and errors).

GET /api/v1/discord/{serverId}​

Returns the public information of a Discord server (guild), read from its public widget.

Route nameAuthRate limit
api.v1.discord.infoNoneapi (60/min)

Path parameters:

NameTypeDescription
serverIdstringDiscord guild id

Query parameters: none.

Responses​

200 OK (application/json)

{
"id": "987654321098765432",
"name": "GroupeZ",
"invite": "https://discord.gg/groupez"
}

id, name and invite are copied as-is from the widget's id, name and instant_invite.

404 Not Found — any failure: widget disabled, unknown guild, network error, or a widget without one of the three fields. The controller calls abort(404) without a message, so with Accept: application/json (and APP_DEBUG off) the body is:

{
"message": ""
}

Example​

curl https://groupez.dev/api/v1/discord/987654321098765432 \
-H "Accept: application/json"

Notes​

  • The data comes from https://discord.com/api/guilds/{serverId}/widget.json; the guild widget must be enabled.
  • Every call reaches Discord: there is no cache, and the Guzzle client is created without a timeout.
  • serverId is not validated before being inserted into the Discord URL.
  • invite can be null if Discord returns instant_invite: null; if the key is absent altogether, the endpoint answers 404.
Copyright © 2026 GroupeZ|Build #loading...|-