GroupeZ Docs
APIv1 (legacy)

Licence

Legacy platform

This page documents the legacy v1 platform (Laravel 8). It is kept for reference and for plugins built against it. The current platform answers the same URI differently: see the v2 licence page.

The licence endpoint is what premium GroupeZ plugins called at start-up to ask whether they were allowed to run. On v1 a licence was bound to three things:

  • a key, generated with Str::random(15), so 15 alphanumeric characters;
  • a plugin, through plugin_licences. The plugin identifies itself with its unique_id, of the form GROUPEZ_ followed by 15 lowercase hexadecimal characters;
  • a list of allowed IP addresses, stored in the ips table. The seller or the customer added them by hand in the dashboard, up to max_ip.

The licence could also have an optional expiry date, expire_at (a date column).

Common behaviour

  • Base URL: https://groupez.dev/api/v1/
  • Middleware: the api group, which is throttle:api, then SubstituteBindings, then TrustCloudflare. There is no session and no CSRF.
  • No version header: v1 does not send X-GroupeZ-Api-Version.

POST /api/v1/licence/{key}

Checks that a licence key exists, has not expired, belongs to the calling plugin, and is being used from an allowed IP address.

Route nameAuthRate limit
api.v1.licenceNonethrottle:api: 60/min, keyed on the user id or else the IP

Path parameters

NameTypeRequiredDescription
keystringyesThe licence key. It is resolved by implicit route-model binding on licences.key ({licence:key}). An unknown key returns 404 before the controller runs.

Query / body parameters

NameTypeRequiredDescription
unique_idstringyesThe plugin's unique_id (plugins.unique_id, for example GROUPEZ_...). It is read with $request->has() / $request['unique_id'], so it can be sent as a form field, a JSON body field or a query-string parameter.

Required header

HeaderDescription
CF-Connecting-IPThe caller's IP address, compared with the licence's allowed IPs. Cloudflare adds this header in front of groupez.dev, so a plugin does not set it itself. If the header is missing, the lookup is done with a null address and fails.

Responses

Errors are 200

Every outcome the controller produces, success or failure, is returned with HTTP status 200. The controller returns a json_encode() string instead of a JSON response, so the Content-Type is Laravel's default for a string response (text/html; charset=UTF-8), not application/json. Clients must parse the body and check status.

The checks run in the order below. The first one that fails decides the answer.

1. Licence expired (expire_at is set and in the past):

{"status":"error","message":"The license has expired"}

2. unique_id missing:

{"status":"error","message":"Unable to find unique id"}

3. The key does not belong to the plugin with this unique_id:

{"status":"error","message":"Unable to find plugin for this licence"}

4. The caller's IP is not on the licence's allowed list (no ips row with adresse = CF-Connecting-IP for this licence):

{"status":"error","message":"Impossible to find the ip address"}

5. Success:

{"status":"success","message":"The license is valid"}

404 Not Found: no licence has this key. The binding fails before the controller runs. The body depends on the Accept header:

  • with Accept: application/json, Laravel 8's default JSON, which names the model and the key (shown with APP_DEBUG off; the production setting is not in the repository, and with debug on exception, file, line and trace keys are added):

    {"message":"No query results for model [App\\Models\\Licence] UNKNOWNKEY"}
  • otherwise, the site's HTML 404 page (resources/views/errors/404.blade.php).

429 Too Many Requests: more than 60 requests per minute. Laravel 8's default ThrottleRequestsException, with Retry-After and X-RateLimit-* headers. With Accept: application/json the body is {"message":"Too Many Attempts."}. Otherwise it is an HTML error page.

405 Method Not Allowed: methods other than POST. Without Accept: application/json the answer is the HTML page errors/405. With it, Laravel 8.26's default JSON (with APP_DEBUG off):

{"message":"The GET method is not supported for this route. Supported methods: POST."}

This endpoint never returns 401, 403 or 422. Validation failures are reported as 200 with "status":"error" (see above).

Example

curl -X POST "https://groupez.dev/api/v1/licence/aB3dE5fG7hI9jK1" \
  -d "unique_id=GROUPEZ_0123456789abcde"
{"status":"success","message":"The license is valid"}

Notes

  • The expiry check ignores the IP. The code reads if (expired) if (!isset($ip)) return error, but $ip is not yet defined at that point. The inner condition is therefore always true, and an expired licence is always refused, before anything else is checked.
  • Expiry granularity. expire_at is a date column. Carbon::parse() turns it into midnight of that day in the app timezone (Europe/Paris), so a licence stops working at 00:00 on its expiry date, not at the end of that day.
  • Side effects on success. Each successful check inserts a licence_logs row (licence_id, ip). Each request that gets past the unique_id/plugin checks also writes a warning line to the licence log channel (storage/logs/licence.log), whether or not the IP is then allowed.
  • IP registration is not part of the API. Allowed IPs were added in the dashboard (Resource\LicenceController::createAdresse / createAdresseAsAdmin, limited to max_ip for the non-admin path). No API endpoint registers an IP. Both dashboard paths validate the address with ip2long(), so only IPv4 addresses could be registered: a server whose CF-Connecting-IP is an IPv6 address could never pass check 4.
  • Empty unique_id. The global ConvertEmptyStringsToNull middleware turns unique_id= into null. The field is still "present" for $request->has(), so the answer is Unable to find plugin for this licence, not Unable to find unique id.
  • Rate-limit key. throttle:api runs before TrustCloudflare in the api group, and the global TrustProxies trusts no proxy. Behind Cloudflare, the IP used as the throttle key is therefore probably the Cloudflare edge address, not the real client. This was inferred from the middleware order and not verified on production.
  • Header spoofing. The allowed-IP check trusts the CF-Connecting-IP header as received. This is only safe if the origin server could not be reached directly, bypassing Cloudflare. Whether the legacy origin was firewalled cannot be determined from the code.
  • Source files (legacy repository): routes/api.php, app/Http/Controllers/Api/LicenceController.php, app/Models/Licence.php, app/Models/Ip.php, app/Models/LicenceLog.php, app/Http/Kernel.php, app/Http/Middleware/TrustCloudflare.php, app/Providers/RouteServiceProvider.php. The legacy repository has no automated test for this endpoint.

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.