Public API · v1
Discord login for your application.
Connect an account from your app to a verified Discord identity. Start a consent flow, check access, and read the result from your backend.
Make your first request
- Create a workspace, then open Servers in the dashboard.
- Add a server with the Custom API (app or service) platform. Save its server ID and one-time secret on your backend.
- Keep authentication set to required when Discord verification is needed. Use the default Plexsur Discord app or configure your own in Settings.
- Send an access check for an account UUID that your backend has verified.
curl https://plexsur.net/api/v1/identity/authorize \
-H "Authorization: Bearer $PLEXSUR_SERVER_SECRET" \
-H "Content-Type: application/json" \
-d '{
"server_id": "srv_your_server_id",
"account_id": "8667ba71-b85c-4a7c-8f3f-0d26c2c4a2f2",
"account_name": "example-user"
}'The request and response examples on this page use illustrative IDs and tokens. Replace them with your own values. “Server” means an integration, including a website or service; “tenant” means a workspace.
Authenticate your backend
Base URL: https://plexsur.net. Integration endpoints use Authorization: Bearer <server-secret> and JSON request bodies. Credentials are scoped to one integration; the API derives the workspace and network from that credential.
Secrets are shown only at creation or rotation. Keep them in environment variables or a secret store. A rotation invalidates the previous secret. Dashboard APIs use a separate HttpOnly session cookie and require workspace membership and roles.
Use a stable account ID
account_id is a hyphenated UUID (versions 1 through 5, case-insensitive). Use an existing UUID from your user database, or assign and persist a random version 4 UUID for each account. For integer IDs, email addresses, Steam IDs, or other identifiers, store a stable mapping to a UUID in your backend.
The optional account_name is a display label of at most 16 characters. It is never proof of ownership. Account IDs are unique within a workspace. Integrations sharing links must use the same UUID for the same person; use separate workspaces for unrelated account directories.
Verify the user's existing account session before sending an ID. For a new signup, your backend can assign a UUID to a pending registration, bind it to a private browser session, and finish registration only after a verified response. Keep that binding when the user returns from Discord.
The login and linking flow
- Your backend calls authorize for the verified local account.
- If
allowis false andreasonisnot_linked, show the returnedchallenge.urlto that user. - The user opens the Plexsur consent page and continues to Discord. Plexsur validates OAuth state, exchanges the code, and reads the basic Discord identity.
- The API checks bans and the linked account limit, records the link and event, and consumes the challenge. The browser lands on
/auth/result. - The user returns to your application. Your backend checks the same account again. With
reason: "linked", an allow decision includes the verifieddiscord_user_id. - Your application grants access or completes its own session. Respect the returned cache duration and recheck when it expires.
The default flow requests identify, without email, bot installation, guild membership, or role permissions. See Discord's OAuth2 documentation for scope definitions. Plexsur does not retain Discord OAuth access tokens.
POST /api/v1/identity/authorize
Requires a Bearer credential. Returns 200 for an evaluated decision, including a denial.
| Field | Required | Meaning |
|---|---|---|
server_id | Yes | Integration ID matching the credential. |
account_id | Yes | Verified, stable account UUID. |
account_name | No | Display label, up to 16 characters. |
Account needs linking
{
"allow": false,
"reason": "not_linked",
"cache_ttl_seconds": 0,
"discord_user_id": null,
"challenge": {
"challenge_id": "chl_example",
"short_token": "ABCD2345EFGH",
"url": "https://plexsur.net/l/ABCD2345EFGH",
"expires_at": "2026-10-01T12:10:00Z",
"account_id": "8667ba71-b85c-4a7c-8f3f-0d26c2c4a2f2",
"server_id": "srv_your_server_id",
"network_id": null
}
}Linked account is allowed
{
"allow": true,
"reason": "linked",
"cache_ttl_seconds": 300,
"discord_user_id": "123456789012345678",
"challenge": null
}| Reason | allow | What to do |
|---|---|---|
linked | true | Use the verified Discord ID and enforce your own app permissions. |
not_linked | false | Show the returned consent URL. |
account_banned | false | Deny access. No challenge is issued. |
discord_banned | false | Deny access. No challenge is issued. |
optional_mode | true | Access was allowed without a link. The Discord ID is null; this is not verified Discord login. |
cache_ttl_seconds is the maximum duration to cache an allow decision. A denial defaults to zero. Cached allow decisions can outlive a new ban or unlink until the TTL expires; use a shorter cache or check each request if immediate enforcement is required.
Create and read a challenge
POST /api/v1/identity/challenges
Requires a Bearer credential. Creates a link directly without first checking for an existing link. Returns 201 with the challenge object shown above, without the outer decision fields. The integration ID comes from the credential.
POST /api/v1/identity/challenges
Authorization: Bearer <integration-secret>
Content-Type: application/json
{
"account_id": "8667ba71-b85c-4a7c-8f3f-0d26c2c4a2f2",
"account_name": "example-user"
}Active account bans reject challenge creation with 403. Discord bans and linked account limits are checked during consent. Challenges expire after ten minutes by default, as configured by the operator; use expires_at as the authoritative expiry.
GET /api/v1/identity/challenges/{short_token}
No credential required. Returns challenge_id, account_id, account_name, account_type (application or minecraft), server_name, network_name, tenant_name, expires_at, and consumed.
The URL is a bearer-like capability: anyone holding it can read that context and attempt consent. Deliver it only to the requesting user. Tokens are case-insensitive. An invalid token returns 404; an expired token returns 410. A consumed but unexpired challenge remains readable with consumed: true.
GET /api/v1/public/oauth/discord/{short_token}
This browser endpoint begins Discord consent and returns a 302 redirect. Prefer sending the user to the returned challenge.url so they can review the account context first. Plexsur handles its own callback at /api/v1/public/oauth/discord/callback; register that exact URL when using a custom Discord app.
Report integration health
POST /api/v1/identity/servers/{server_id}/heartbeat requires that integration's Bearer credential. Send a version label of 1 to 64 characters. A heartbeat marks the integration online; keep sending one about every 30 seconds while your backend is connected.
POST /api/v1/identity/servers/srv_your_server_id/heartbeat
Authorization: Bearer <integration-secret>
Content-Type: application/json
{ "integration_version": "1.0.0" }
// 200 OK
{
"ok": true,
"server_id": "srv_your_server_id",
"status": "online",
"protocol_version": "1"
}Backend example in JavaScript
Set PLEXSUR_SERVER_ID and PLEXSUR_SERVER_SECRET in your backend environment. Pass the UUID from your verified local session. Show the consent URL only to that session's user, and call this function again when they return.
// Run on your backend after verifying the current user's session.
// accountUuid comes from your database, never from a client-supplied ID.
async function checkDiscordAccess(accountUuid) {
const response = await fetch(
"https://plexsur.net/api/v1/identity/authorize",
{
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.PLEXSUR_SERVER_SECRET,
"Content-Type": "application/json"
},
body: JSON.stringify({
server_id: process.env.PLEXSUR_SERVER_ID,
account_id: accountUuid
})
}
);
if (!response.ok) {
throw new Error("Plexsur access check failed: " + response.status);
}
const decision = await response.json();
if (decision.allow && decision.reason === "linked" && decision.discord_user_id) {
return { status: "verified", discordId: decision.discord_user_id };
}
if (!decision.allow && decision.reason === "not_linked" && decision.challenge) {
return { status: "link_required", url: decision.challenge.url };
}
return { status: "denied", reason: decision.reason };
}Handle API failures by showing a retry state and denying unverified access. Bind any application session you create to the verified local account and Discord ID.
Errors and retries
HTTP errors generally return { "detail": "message" }. Validation errors use an array of field errors in detail. Responses include X-Request-ID for support and debugging; avoid logging credentials and challenge tokens.
| Status | Meaning | Handling |
|---|---|---|
| 401 | Missing, invalid, or rotated credential. | Check the backend secret. |
| 403 | Credential scope mismatch or account prohibited. | Check the integration ID or contact an administrator. |
| 404 | Challenge not found. | Ask the application for a new link. |
| 410 | Challenge expired; OAuth start also rejects consumed challenges. | Create a new linking request. |
| 422 | Invalid UUID, missing field, or invalid field length. | Correct the request body. |
| 426 | Unsupported optional X-SolusLabs-Protocol header. | Omit the header or send 1. |
| 429 | Too many authentication attempts. | Back off; do not create a new challenge on each poll. |
| 503 / 5xx | Provider not configured or service unavailable. | Show a retry state. Do not assume identity was verified. |
Challenge creation permits up to eight requests per account and integration in five minutes; starting OAuth permits eight attempts per challenge in ten minutes. Invalid machine credentials are limited to 60 failures per source IP and route scope in a minute. OAuth callbacks are also limited per IP.
Authorize can create a challenge when there is no link, so it is not idempotent. Keep the returned URL instead of polling authorize while consent is pending. Recheck on user return, or after a verified webhook tells your backend that the link was recorded.
Receive account events
Configure a webhook in dashboard Settings. Supported events include account.linked, authentication.success, and authentication.failed. Payloads use the following envelope:
{
"id": "evt_example",
"event": "account.linked",
"tenant_id": "ten_example",
"data": {
"minecraft_uuid": "8667ba71-b85c-4a7c-8f3f-0d26c2c4a2f2",
"discord_user_id": "123456789012345678",
"server_id": "srv_your_server_id"
}
}For compatibility, webhook data currently calls the account UUID minecraft_uuid, including for Custom API integrations. Use it as your application's account UUID. Dashboard APIs retain the same legacy field names.
Verify X-SolusLabs-Signature: t=<unix-seconds>,v1=<hex-digest> with HMAC-SHA256 over timestamp + "." + rawRequestBody, using the webhook secret and the exact bytes received. Compare signatures in constant time, reject timestamps outside your accepted window (for example five minutes), and deduplicate the envelope's id.
Return a 2xx status after accepting the event. Failed deliveries retry after 60, 300, 900, and 3600 seconds, up to five total attempts. A webhook confirms the recorded event; check authorize again for the current access decision.
Minecraft is an integration
Velocity, Paper, and Fabric continue to use /api/v1/minecraft with minecraft_uuid and minecraft_username fields. These routes share the same linking, credential, ban, and policy logic as the identity API. Minecraft requires a platform-verified online UUID.
Open Minecraft installation instructions · Read the security model