GroupeZ Docs
APIv1 (legacy)

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.

On this page

GroupeZ MCP

Documentation in your AI

MCP lets your AI search GroupeZ documentation and read current examples to help you configure your plugins.

Read-only · No documentation API key needed

1. Copy the server URL

Transport: HTTP (Streamable HTTP). Authentication: none. Your tool uses its own model and subscription limits.

  1. Open Plugins in ChatGPT on the web, then + → Add custom MCP server.
  2. Name it ‘GroupeZ Documentation’, paste the URL above and choose ‘No authentication’. Create and install the plugin.
  3. In a conversation, type @ and select the plugin before asking. If needed, enable search, get_page and list_pages in its settings.
Official setup guide

3. Ask a question

Use GroupeZ MCP to help me create a zMenu inventory. Search relevant pages, read the examples and cite your sources.
Other clients and troubleshooting

For clients using mcpServers, adapt this example to their format:

{
  "mcpServers": {
    "groupez-docs": {
      "url": ""
    }
  }
}

Available tools are search, get_page and list_pages. Refresh the connector’s tools after updates. If your AI cannot access them, check that the connector is enabled in the conversation and your account allows custom MCP servers. Opening the URL in a browser is not an MCP test.