GroupeZ Docs
APIv2 (current)

Discord

Cette page est affichée en anglais car sa traduction française n’est pas encore disponible.

These endpoints are called by Discord bots: to sign a member in with Discord, to read which resources a Discord member owns (for example to hand out roles), to grant a resource to a member, and to read the public information of a Discord server.

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

Same URIs as the legacy platform

The current platform keeps the /api/v1/discord/... URIs, verbs, body fields and JSON keys of the legacy platform so that bots already deployed keep working. The differences are listed in the notes of each endpoint.

Overview

MethodURIRoute namePurpose
GET/api/v1/discord/authenticationapi.v1.discord.authRedirects to the Discord sign-in flow
POST/api/v1/discordapi.v1.discord.storeResources owned by a Discord member
POST/api/v1/discord/{user}api.v1.discord.userA Discord member and the resources they own
POST/api/v1/discord/{user}/apply/{resource}api.v1.discord.grantGrants a resource to a Discord member (shared secret)
GET/api/v1/discord/{serverId}api.v1.discord.serverPublic information of a Discord server

Common behaviour

Identifying a member

{user} (and the discord body field) is a Discord user id (a snowflake, e.g. 123456789012345678), never a groupez.dev user id. It is resolved through the Discord account the member linked on groupez.dev (the social_accounts table, provider discord). A snowflake that nobody linked, or that belongs to a deleted account, answers 404.

Headers

Every response of the API, errors included, carries:

X-GroupeZ-Api-Version: 1

The API is sessionless: no cookie and no CSRF token are needed. Sending Accept: application/json is recommended, although every error under /api/v1/ is rendered as JSON regardless.

Rate limit

All Discord endpoints use the api limiter: 120 requests per minute, keyed by IP address (the API carries no session, so there is no authenticated user to key on). Above the limit:

429 Too Many Requests

{
  "message": "Too many requests."
}

The Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers are forwarded when the limiter sets them.

Errors

Every 404 under /api/v1/ is rewritten to the same neutral body, whatever the cause (unknown member, unknown resource, closed widget):

{
  "message": "Not found."
}

Route declaration order

Declaration order matters

authentication and {serverId} are both a single segment answered by GET. GET /discord/authentication is declared before GET /discord/{serverId}, otherwise every sign-in would be read as a lookup of a guild called authentication. In the same way, 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 the same list of accesses. Each item is spelled the way the legacy platform spelled a plugin_accesses row:

KeyTypeDescription
idintegerAccess id
user_idintegergroupez.dev user id of the member
plugin_idintegerId of the resource (the legacy platform called resources plugins)
payment_idinteger | nullInternal id of the payment behind the access, null when no payment backs it (gift, manual grant...)
created_atstring | nullISO 8601 timestamp (e.g. 2026-10-15T12:00:00.000000Z)
updated_atstring | nullISO 8601 timestamp

Only active accesses are listed: an access that was revoked (refund, manual revocation) is never returned. Items are ordered by id, ascending. The list is a bare JSON array, never wrapped in a data key.


GET /api/v1/discord/authentication

Legacy entry point of the "link your Discord account" flow. On the current platform it no longer performs the OAuth exchange: it redirects the visitor to the standard Discord sign-in flow of groupez.dev.

Route nameAuthRate limit
api.v1.discord.authNoneapi (120/min)

Path parameters: none.

Query parameters: none are read. code and state sent by the legacy OAuth callback are deliberately ignored and dropped from the redirect.

Responses

302 Found

Location: https://groupez.dev/auth/discord/redirect

The target (oauth.redirect) then sends the visitor to Discord. That route belongs to the website, not to the API, and has its own rate limit (auth, 5 per minute).

Example

curl -i https://groupez.dev/api/v1/discord/authentication

Notes

  • This endpoint is meant to be opened in a browser, not called by a bot.
  • The redirect is a 302, not a 301, so browsers do not cache it permanently.
  • An authorization code issued for the legacy callback cannot be reused: the visitor has to go through the Discord consent screen again.

POST /api/v1/discord

Returns the active accesses of a Discord member, identified by the discord body field. Despite the verb, nothing is written.

Route nameAuthRate limit
api.v1.discord.storeNoneapi (120/min)

Path parameters: none.

Body parameters (JSON or form-encoded):

NameTypeRequiredDescription
discordstringYesDiscord user id (snowflake) of the member

Responses

200 OK — a bare array of accesses (possibly empty):

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

400 Bad Request — discord is missing, blank (only whitespace) or not a string:

{
  "message": "Discord not found"
}

404 Not Found — no account linked that snowflake (or the account was deleted):

{
  "message": "Not found."
}

429 Too Many Requests — see Rate limit.

Example

curl -X POST https://groupez.dev/api/v1/discord \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"discord": "123456789012345678"}'

Notes

  • The controller aborts with the message User not found, but every 404 under /api/v1/ is rewritten to {"message": "Not found."}. Branch on the status code, not on the message.
  • Send the snowflake as a JSON string. A JSON number ({"discord": 123456789012345678}) is not a string and answers 400; snowflakes also exceed the safe integer range of most JSON parsers.
  • A member who exists but owns nothing gets 200 with [], not a 404.
  • Revoked accesses are filtered out, so a refunded purchase disappears from the list.

POST /api/v1/discord/{user}

Returns a Discord member (name, groupez.dev id, avatar) together with their active accesses. Despite the verb, nothing is written.

Route nameAuthRate limit
api.v1.discord.userNoneapi (120/min)

Path parameters:

NameTypeDescription
userstringDiscord user id (snowflake) of the member

Body parameters: none.

Responses

200 OK

{
  "user": {
    "name": "Bobby",
    "id": 7,
    "avatar": null
  },
  "plugins": [
    {
      "id": 42,
      "user_id": 7,
      "plugin_id": 12,
      "payment_id": 1534,
      "created_at": "2026-10-15T12:00:00.000000Z",
      "updated_at": "2026-10-15T12:00:00.000000Z"
    }
  ]
}
KeyTypeDescription
user.namestringgroupez.dev display name
user.idintegergroupez.dev user id
user.avatarstring | nullAbsolute URL of the uploaded avatar, or null when the member has none
pluginsarrayActive accesses, ordered by id

404 Not Found — no account linked that snowflake (or the account was deleted):

{
  "message": "Not found."
}

429 Too Many Requests — see Rate limit.

Example

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

Notes

  • avatar can be null. The legacy platform always returned a URL, falling back to a default image; clients must handle null.
  • No other member data is exposed (no email, role or ban state).
  • GET /api/v1/discord/{id} hits a different endpoint (server information): the verb matters.

POST /api/v1/discord/{user}/apply/{resource}

Grants a resource to a Discord member. This is the only endpoint of the Discord group that writes, and it is protected by a shared secret.

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

Path parameters:

NameTypeDescription
userstringDiscord user id (snowflake) of the member
resourceintegergroupez.dev resource id. Only Published and Unlisted resources resolve; any other id answers 404

Body parameters:

NameTypeRequiredDescription
custom_passwordstringYesThe shared secret configured on the server under services.discord.purchase_secret (environment variable DISCORD_PURCHASE_SECRET)

Responses

200 OK — the access was granted (or a revoked access was restored):

{
  "message": "ok"
}

200 OK — the member already had an active access to this resource, nothing changed:

{
  "message": "ko"
}

403 Forbidden — the secret is missing, wrong, not a string, or no secret is configured on the server. The body never says which:

{
  "message": ""
}

404 Not Found — unknown or non-public resource, or no account linked that snowflake:

{
  "message": "Not found."
}

429 Too Many Requests — see Rate limit.

Example

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

Notes

Evaluation order

  1. The {resource} binding runs first: an unknown or non-public resource answers 404 before the secret is checked.
  2. The secret is checked next: a wrong secret answers 403, even for an unknown member.
  3. The member is looked up last: an unknown snowflake answers 404.
  • The secret is compared in constant time (hash_equals). When services.discord.purchase_secret is not set or is empty, every call is refused with 403.
  • ko is not an error: it means "already owned", and the status is still 200. A bot can use it to decide whether to announce the grant.
  • Calling the endpoint again is idempotent: at most one access row exists per member and resource. A previously revoked access is reactivated rather than duplicated (ok is returned in that case).
  • The access is recorded with the source manual and no payment (payment_id is null in the accesses payload), so it is not counted as a sale.
  • Each successful grant is written to the activity log (access.granted, via: api.v1.discord.grant) with a hashed IP address.
  • The 403 body is the framework's default JSON rendering of an abort(403) without a message: with APP_DEBUG off it is {"message": ""} (the tests only assert the status and that the body leaks nothing about the secret). Rely on the status code only.

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.serverNoneapi (120/min)

Path parameters:

NameTypeDescription
serverIdstringDiscord guild id (digits only)

Query parameters: none.

Responses

200 OK

{
  "id": "987654321098765432",
  "name": "GroupeZ",
  "invite": "https://discord.gg/groupez"
}
KeyTypeDescription
idstringGuild id
namestringGuild name
invitestring | nullInstant invite exposed by the widget, null when the widget has none

404 Not Found — the widget is disabled, the guild does not exist, serverId is not made of digits only, or Discord could not be reached:

{
  "message": "Not found."
}

429 Too Many Requests — see Rate limit.

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 server widget must be enabled in the guild settings, otherwise the answer is 404. No bot token is used.
  • Only id, name and invite are returned; the member list of the widget is never forwarded.
  • Successful answers are cached for 10 minutes (groupez.discord.widget_cache_seconds). Failures are not cached: enabling the widget takes effect on the next call.
  • The call to Discord times out after 5 seconds (groupez.discord.timeout_seconds); a timeout answers 404.
  • A serverId that is not purely numeric is rejected without calling Discord.
  • GET /api/v1/discord/authentication is declared before this route and is never treated as a guild id (see Route declaration order).

Sur cette page

GroupeZ MCP

La documentation dans ton IA

Le MCP permet à ton IA de chercher dans la documentation GroupeZ et de lire les exemples à jour pour t’aider à configurer tes plugins.

Lecture seule · Sans clé API côté documentation

1. Copie l’adresse du serveur

Transport : HTTP (Streamable HTTP). Authentification : aucune. Ton outil utilise son propre modèle et ses propres limites d’abonnement.

  1. Ouvre Plugins dans ChatGPT sur le web, puis + → Ajouter un serveur MCP personnalisé.
  2. Nomme-le « GroupeZ Documentation », colle l’URL ci-dessus et choisis « Aucune authentification ». Crée puis installe le plugin.
  3. Dans une conversation, tape @ et sélectionne ce plugin avant d’envoyer ta question. Si nécessaire, active search, get_page et list_pages dans ses paramètres.
Guide officiel de cet outil

3. Pose une question

Utilise le MCP GroupeZ pour m’aider à créer un inventaire zMenu. Recherche les pages pertinentes, lis les exemples et cite tes sources.
Autres clients et dépannage

Pour les clients utilisant mcpServers, adapte cet exemple à leur format :

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

Les outils disponibles sont search, get_page et list_pages. Après une mise à jour, actualise les outils du connecteur. Si ton IA n’y accède pas, vérifie que le connecteur est activé dans la conversation et que ton compte autorise les MCP personnalisés. Ouvrir l’URL dans un navigateur n’est pas un test MCP.