go-auth
Guides

Admin

Admin login, platform-wide user management, per-user session control, and admin-issued platform invites.

Admin

This guide's fixed admin/user role examples apply when app permissions are disabled. With WithAppPermissions enabled, app administrative operations can be delegated individually to custom roles through installed library keys; organization oversight and catalog provisioning remain protected-admin-only. See the app permission guide for bootstrap, enabled-mode role payloads/filters, direct service calls and current MFA enforcement.

Normal authentication runs first. Missing/expired sessions fail as usual; authorization denial returns 403 forbidden, and missing required second-factor assurance returns 403 two_factor_required. Auth.RequireAdmin always requires the protected administrator, even when other routes permit delegated operators.

One distinction that applies to every admin route: user_not_found (404) is returned only when the target user genuinely does not exist. If the database read for the target user fails instead — an outage, a canceled context — the route answers internal_error (500) and logs the cause server-side, so a temporary backend problem is never reported as a missing user. The same rule covers the middleware: session validation or transparent-refresh failures due to backend trouble produce internal_error, never 401 unauthorized — see Error handling.

For the full path/param reference see Routes. For viewing what admins (and everyone else) have done, see Audit Logs.

The curl mutation examples use jq to read the mandatory CSRF bootstrap JSON. Keep the same cookie jar for bootstrap and the following request; the bootstrap is repeated after authentication changes.

Configuration

There's no WithAdmin flag — the existing administrative routes are always mounted. Access uses the fixed account role when app permissions are off and current app authorization when on. TwoFactorConfig.DisableAdminTwoFactor opts out of the default admin requirement; global and per-user requirements still apply. Custom roles with delegated administrative permissions follow user MFA settings.

Bootstrapping the first admin

Every admin-management endpoint below requires an admin session to call — including POST /admin/users. That's a chicken-and-egg problem for the very first admin: there's no API route that creates one from nothing. Use the goauth seed-admin CLI command — see Creating the first admin below.

Creating the first admin

goauth seed-admin writes an admin user directly to the database, bypassing the API entirely — it's the supported answer to the chicken-and-egg problem above, so you don't have to hand-write an UPDATE users SET role = 'admin' WHERE email = '...' against production.

The standalone CLI does not load your application's Config, so it writes this bootstrap password as an unpeppered bcrypt row (password_pepper_version = NULL). In an application with versioned password peppering enabled, the first successful admin password check verifies that explicit legacy format and upgrades the row through the normal guarded rehash-on-login path before issuing the 2FA challenge or session. Until that first login, the bootstrap row has bcrypt protection but not the optional pepper layer.

go run github.com/nazimdjebloun/go-auth/cmd/goauth seed-admin \
  --driver postgres --dsn "$DATABASE_URL" --env prod \
  --smtp-host smtp.example.com --smtp-from auth@example.com

Because admin login requires a second factor by default, a seeded admin who can't receive email may never be able to log in — so this command sends a real verification email to the address you're seeding, before writing anything to the database, and aborts with nothing written if that send fails. This check runs regardless of whether the deployment you're seeding for has set TwoFactorConfig.DisableAdminTwoFactor — the CLI has no way to know your running server's config, so it conservatively assumes the default (2FA on). --driver, --dsn, and --env are required; --env is dev, staging, or prod and controls which mailer default applies.

That email is fixed HTML-and-text content with no app name, links, or configurable parts — it lives in cmd/goauth/cmd/seed_admin_email.go and, unlike the library's emails, does not go through TemplateProvider. A compiled CLI has no way to accept a custom provider from an operator, so there is nothing to swap.

InputFlagEnv varNotes
Admin email—ADMIN_EMAILPrompted for interactively if unset and stdin is a TTY; a hard error otherwise.
Admin password—ADMIN_PASSWORDValidated against the default password policy if set. Otherwise prompted (hidden input) with fallback to a strong auto-generated password, printed once and never shown again.
SMTP config--smtp-host, --smtp-port, --smtp-from, --smtp-user, --smtp-pass, --smtp-tlsSMTP_HOST, SMTP_PORT, SMTP_FROM, SMTP_USER, SMTP_PASS, SMTP_TLSRequired unless --env dev (see below) or --skip-mailer-check. The port defaults to 587; an explicit flag overrides SMTP_PORT. Invalid or out-of-range ports fail before seeding.
Force non-interactive--non-interactive—Missing ADMIN_EMAIL fails immediately instead of prompting — for CI.
Skip the mailer send--skip-mailer-check—Still builds a mailer if SMTP config is present; just doesn't require the send to succeed. Prints a loud warning either way.
Allow a second admin--force—Without it, seeding refuses outright if any role: admin row already exists.

--env dev with no SMTP flags/env set uses a log-only mailer instead of real SMTP — the same LogMailer NewConfig defaults to automatically in EnvironmentDev. The "verification email" is written to the command's log output instead of actually sent, so local bootstrapping needs no SMTP setup at all:

go run github.com/nazimdjebloun/go-auth/cmd/goauth seed-admin \
  --driver sqlite --dsn ./dev.db --env dev

If email/password aren't supplied and stdin is a TTY, both are prompted for; otherwise both auto-generate or error per the table above. The command refuses to write anything to the database if the mailer send fails or if an admin already exists without --force — see the flag table above.

Frontend client setup

The Client examples below use the admin-specific apiRequest(method, path, body) fetch wrapper, which sends X-GoAuth-Session: admin on every request. Use it for bootstrap, login, 2FA, reads, refresh, and logout. The named-method wrapper for everything on this page lives on Client → Admin.

Admin login

Admin login — POST /auth/admin/login

Same request shape as Login, but only succeeds for users whose role is "admin" — a correct password for a non-admin account fails exactly like a wrong password, and both count as a failed admin-login attempt in the audit log.

Request body

FieldTypeRequired
emailstringRequired
passwordstringRequired

Admin login requires a second factor by default

POST /auth/admin/login sends a 2FA challenge regardless of RequireEmail2FA and the account's TwoFactorEnabled setting. It therefore requires a mailer unless TwoFactorConfig.DisableAdminTwoFactor is set. The same default applies to ordinary admin password login, admin OAuth login, and privileged HTTP access. Global/per-user requirements still apply when the admin default is disabled. See Configuration and Security.

Response (200 OK) — with the default admin 2FA policy, a correct password returns a gated challenge, the same shape Login uses:

{
  "user": { "...": "role: admin" },
  "step": "two_factor",
  "codeSent": true,
  "challengeId": "c4a1...",
  "expiresAt": "2026-08-09T12:05:00Z",
  "message": "Two-factor code sent to your email"
}

No session/refresh cookie is set on the challenge response — only the 2FA binding cookie. Complete the login with POST /auth/2fa/verify ({challengeId, code}) using the code emailed to the admin; that call is what actually sets goauth_admin_session/goauth_admin_refresh. See Two-factor authentication for the full request/response shape.

If DisableAdminTwoFactor is true and neither global nor per-user 2FA is required, login instead returns the ordinary login result with user and sets the session/refresh cookies immediately. Always branch on step: "two_factor" rather than assuming a challenge.

Errors

CodeStatusCause
invalid_json400Malformed body
invalid_credentials401Wrong password, unknown email, an OAuth-only account with no password, or correct credentials for a non-admin user — all indistinguishable on purpose
user_banned403Account is banned
email_not_configured500No mailer configured — see the callout above
internal_error500Session creation failure

curl

# 1. Password step — returns a challenge, not a session
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/auth/admin/login \
  -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -c admin-cookies.txt \
  -d '{"email":"root@myapp.com","password":"a very strong admin password 1"}'

# 2. Second-factor step — this is what actually sets the session cookies
CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/auth/2fa/verify \
  -H "X-CSRF-Token: $CSRF" \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt -c admin-cookies.txt \
  -d '{"challengeId":"c4a1...","code":"482913"}'

App and admin cookies have different names, so one cookie jar can hold both sessions. These examples use admin-cookies.txt for convenience. The header selects the admin cookie and CSRF context; it does not grant authorization.


Programmatic (Go)

Go examples on this page import github.com/nazimdjebloun/go-auth/api for operation inputs and results.

result, err := auth.Services().Auth.AdminLogin(ctx, api.LoginInput{
    Email:     "root@myapp.com",
    Password:  "a very strong admin password 1",
    IP:        r.RemoteAddr,
    UserAgent: r.UserAgent(),
})
if err != nil {
    // wraps a *domain.AuthError — invalid_credentials covers "not an admin" too
    return
}
if result.RequiresTwoFactor {
    // Retain result.TwoFactorChallenge and securely bind result.BindingToken()
    // to this client before accepting its emailed code.
    verified, err := auth.Services().TwoFactor.Verify(ctx, api.TwoFactorVerifyInput{
        ChallengeID: result.TwoFactorChallenge, BindingToken: result.BindingToken(), Code: code,
        IP: r.RemoteAddr, UserAgent: r.UserAgent(),
    })
    if err != nil {
        // handle the error and stop before setting session cookies
        return
    }
    auth.SetSessionCookies(w, verified.SessionToken, verified.RefreshToken)
} else {
    // Admin 2FA opted out and no global/per-user requirement applies.
    auth.SetSessionCookies(w, result.SessionToken, result.RefreshToken)
}

Client

const result = await apiRequest("POST", "/auth/admin/login", { email, password });
if (result.step === "two_factor") {
  setChallengeId(result.challengeId); // show the code screen
} else {
  setAdminUser(result.user); // session cookies are already set
}

// In the code screen, after the admin enters the code:
const verified = await apiRequest("POST", "/auth/2fa/verify", {
  challengeId,
  code,
});
setAdminUser(verified.user); // session cookies are now set

Listing and inspecting users

List users — GET /admin/users

Query params

ParamTypeDefaultNotes
offsetint0
limitint20Max 100
emailstring—Exact match
searchstring—Matches name or email
rolestring—admin or user only — any other value is silently ignored, not applied as a filter
isBannedbool—true or false — anything else is ignored, not applied as a filter
isVerifiedbool—true or false — anything else is ignored, not applied as a filter
twoFactorEnabledbool—true or false — anything else is ignored, not applied as a filter
neverLoggedInbool—true only — last_login_at IS NULL. Registered but never logged in since.
lastLoginBeforeRFC3339 timestamp—last_login_at < X, excluding users who've never logged in (that's neverLoggedIn's job) — "logged in before, gone dormant since." Composable with neverLoggedIn for two different questions in one dashboard.
orderBystringcreated_atOr updated_at; an explicitly supplied unsupported value is a 400
orderDirectionstringdescOr asc; an explicitly supplied unsupported value is a 400

Response (200 OK)

{
  "users": [
    {
      "id": "b3f1...",
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "role": "user",
      "isVerified": true,
      "isBanned": false,
      "orgOwnerCount": 0,
      "createdAt": "2026-08-09T12:00:00Z",
      "updatedAt": "2026-08-09T12:00:00Z"
    }
  ],
  "limit": 20,
  "offset": 0
}

The list response carries no total — fetch it from Count users below.

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users?limit=20&search=ada&role=user" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

search := "ada"
result, err := auth.Services().Admin.ListUsers(ctx, api.AdminListUsersInput{
    ActorID: adminID,
    Limit:   20,
    Search:  &search,
})
// result.Users

Client

const { users } = await apiRequest("GET", "/admin/users?limit=20&search=ada");

Count users — GET /admin/users/count

The matching total for List users, split into its own call: List no longer runs a COUNT(*) on every page, so a paginated table fetches the count once per filter change instead of once per page.

Query params: the filter params of List users (email, search, role, isBanned, isVerified, twoFactorEnabled, neverLoggedIn, lastLoginBefore). offset and limit are ignored. orderBy and orderDirection are also ignored after validation: omitted values use the list defaults, and explicitly unsupported values return 400 invalid_input.

Response (200 OK): { "count": 150 }

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/count?role=user&isBanned=true" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

roleUser := domain.RoleUser
n, err := auth.Services().Admin.CountUsers(ctx, api.AdminListUsersInput{
    ActorID: adminID,
    Role:    &roleUser,
})

Client

const { count } = await apiRequest("GET", "/admin/users/count?role=user");

Get user detail — GET /admin/users/{id}

Bundles the user record with their active session count, whether they can sign in with a password, and linked OAuth provider accounts — one call instead of several.

Response (200 OK)

{
  "user": { "...": "same shape as list" },
  "activeSessionCount": 2,
  "hasPassword": true,
  "providers": [{ "provider": "github", "providerUserID": "12345" }]
}

hasPassword is false for an OAuth-only account that never set a password. The hash itself is never returned.

Errors

CodeStatusCause
user_not_found404No user with that ID

curl

curl -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/users/b3f1... \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

detail, err := auth.Services().Admin.GetUserDetail(ctx, api.GetUserDetailInput{
    UserID:  userID,
    ActorID: adminID, // the admin making this call
})
// detail.User, detail.ActiveSessionCount, detail.Providers

Client

const detail = await apiRequest("GET", `/admin/users/${userId}`);

Creating and managing users

Create a user — POST /admin/users

Creates the user pre-verified — isVerified: true from the moment it's created, no email round-trip. There's no session issued; this is admin provisioning, not a login. Password authentication must be enabled; otherwise this password-based account-creation operation returns 405 method_disabled.

Request body

FieldTypeRequired
emailstringRequired
passwordstringRequired — must pass SecurityConfig.PasswordPolicy
namestringRequired — rejected as name_required if blank after trimming
rolestringOptional — anything other than "admin" becomes "user"

Response (201 Created) — the created user object, same shape as list.

Errors

CodeStatusCause
invalid_json400Malformed body
name_required400Blank name
weak_password400Fails PasswordPolicy
email_already_exists409An account with that email already exists
internal_error500Hashing or database failure

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/users \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"email":"newadmin@myapp.com","password":"a very strong password 1","name":"New Admin","role":"admin"}'

Programmatic (Go)

user, err := auth.Services().Admin.CreateUser(ctx, api.CreateUserInput{
    ActorID:  adminID,
    Email:    "newadmin@myapp.com",
    Password: "a very strong password 1",
    Name:     "New Admin",
    Role:     "admin",
})

Client

const user = await apiRequest("POST", "/admin/users", {
  email: "newadmin@myapp.com",
  password: "a very strong password 1",
  name: "New Admin",
  role: "admin",
});

Change a user's role — PATCH /admin/users/{id}/role

Request body: { "role": "admin" } — must be exactly "user" or "admin".

Response: { "message": "Role updated" }

Errors

CodeStatusCause
invalid_role400role wasn't "user" or "admin"
user_not_found404No user with that ID
last_admin400Would demote the only remaining admin

The last-admin check only fires on demotion

Promoting a user to admin is never blocked. The last_admin check only runs when the target is currently an admin and the new role is "user" — it exists purely to stop you from locking yourself (or everyone) out.

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/users/b3f1.../role \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"role":"admin"}'

Programmatic (Go)

err := auth.Services().Admin.UpdateUserRole(ctx, api.UpdateUserRoleInput{
    UserID:  userID,
    Role:    "admin",
    ActorID: adminID, // the admin making this call
})

Client

await apiRequest("PATCH", `/admin/users/${userId}/role`, { role: "admin" });

Ban / unban a user — PATCH /admin/users/{id}/ban, PATCH /admin/users/{id}/unban

Banning revokes every one of that user's sessions immediately, in the same request — a banned user can't keep using an already-open tab. AuthService.ValidateSession also checks IsBanned on every request going forward, so even a session created after the ban (there isn't one, since sessions are revoked) or a stale in-memory session on the client would still be rejected server-side.

Response: { "message": "User banned successfully" } / { "message": "User unbanned successfully" }

Errors

CodeStatusCause
user_not_found404No user with that ID
already_banned400Ban called on an already-banned user
not_banned400Unban called on a user who isn't banned
last_admin400Ban would target the only remaining admin

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/users/b3f1.../ban \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Admin.BanUser(ctx, api.BanUserInput{UserID: userID, ActorID: adminID})
err  = auth.Services().Admin.UnbanUser(ctx, api.UnbanUserInput{UserID: userID, ActorID: adminID})

Client

await apiRequest("PATCH", `/admin/users/${userId}/ban`);

Delete a user — DELETE /admin/users/{id}

Revokes all their sessions, then deletes the row. Irreversible.

Response: { "message": "User deleted successfully" }

Errors

CodeStatusCause
user_not_found404No user with that ID
last_admin400Would delete the only remaining admin

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1... \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Admin.DeleteUser(ctx, api.DeleteUserInput{UserID: userID, ActorID: adminID})

Client

await apiRequest("DELETE", `/admin/users/${userId}`);

Managing a user's sessions

Same session shape and semantics as Sessions, just scoped to any user by ID instead of "yourself."

List a user's sessions — GET /admin/users/{id}/sessions

Query: offset, limit (default 20, max 100).

Response: { "sessions": [...], "total": 5 }

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/b3f1.../sessions?limit=20" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

sessions, total, err := auth.Services().Admin.ListUserSessions(ctx, api.AdminListUserSessionsInput{
    ActorID: adminID, // the admin making this call
    UserID:  userID,
    Limit:   20,
})

Client

const { sessions, total } = await apiRequest("GET", `/admin/users/${userId}/sessions`);

Revoke one / all of a user's sessions — DELETE /admin/users/{id}/sessions/{sessionId}, DELETE /admin/users/{id}/sessions

Errors (single-session route)

CodeStatusCause
user_not_found404No user with that ID
session_not_found404Session doesn't exist or doesn't belong to that user

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions/9e2c... \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/users/b3f1.../sessions \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Admin.RevokeUserSession(ctx, api.RevokeUserSessionInput{
    UserID: userID, SessionID: sessionID, ActorID: adminID,
})
err  = auth.Services().Admin.RevokeUserSessions(ctx, api.RevokeUserSessionsInput{
    UserID: userID, ActorID: adminID,
}) // all of them

Client

await apiRequest("DELETE", `/admin/users/${userId}/sessions/${sessionId}`);
await apiRequest("DELETE", `/admin/users/${userId}/sessions`);

Sessions across every user — GET /admin/sessions

The incident-response view: active sessions platform-wide, not scoped to one user. Same base condition as ListUserSessions (not revoked, not yet expired), plus optional filters.

Query (all optional): offset, limit (default 20, max 100); userId, ip, search (substring-matches ip/user agent); createdAfter, createdBefore, expiresAfter, expiresBefore, lastActiveAfter, lastActiveBefore (RFC3339); orderBy (created_at default, expires_at, last_active_at); orderDirection (desc default, asc).

Response: { "sessions": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/sessions/count (below).

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/sessions?ip=203.0.113.9" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

ip := "203.0.113.9"
result, err := auth.Services().Admin.ListSessions(ctx, api.AdminListSessionsInput{
    ActorID: adminID,
    IP:      &ip,
    Limit:   20,
})

Client

const { sessions } = await apiRequest("GET", "/admin/sessions?ip=203.0.113.9");

Count sessions — GET /admin/sessions/count

Total for the filter above, on its own call (same rationale as Count users). Takes every filter param of GET /admin/sessions; offset/limit are ignored, and orderBy/orderDirection are validated then ignored.

Response: { "count": 5 }

n, err := auth.Services().Admin.CountSessions(ctx, api.AdminListSessionsInput{
    ActorID: adminID, IP: &ip,
})
const { count } = await apiRequest("GET", "/admin/sessions/count?ip=203.0.113.9");

Bulk user actions — POST /admin/users/bulk/{ban,unban,delete,revoke-sessions}

Each endpoint applies the same single-user action (BanUser, UnbanUser, DeleteUser, RevokeUserSessions) to a list of user IDs. This is not atomic — go-auth has no batch primitive, so each ID is a separate sequential call under the hood. One user's failure (already banned, last admin, not found) doesn't stop or roll back the rest; read succeeded/failed rather than treating a 200 as "all succeeded."

Body: { "userIds": ["id1", "id2", ...] } — 1 to 100 IDs.

Response: { "succeeded": ["id1"], "failed": [{ "userId": "id2", "code": "last_admin", "message": "Cannot ban the last admin" }] }

code is the same stable AuthError code a single-user call would return — safe to match on.

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/users/bulk/ban \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt \
  -d '{"userIds": ["b3f1...", "9e2c..."]}'

Programmatic (Go)

result, err := auth.Services().Admin.BulkBanUsers(ctx, api.BulkUserActionInput{
    UserIDs: []string{"b3f1...", "9e2c..."},
    ActorID: adminID,
})
// result.Succeeded, result.Failed

The same shape applies to BulkUnbanUsers, BulkDeleteUsers, and BulkRevokeUserSessions.


Client

const result = await apiRequest("POST", "/admin/users/bulk/ban", {
  userIds: ["b3f1...", "9e2c..."],
});

Stats and activity

Three read-only endpoints for an admin dashboard — an overview snapshot, a registrations-over-time trend, and a GitHub-commit-style daily login-activity heatmap. All three read from data go-auth already tracks (users.last_login_at, users.created_at, and the audit log) — nothing new is stored just for this.

The login endpoints need WithAudit(Enabled: true)

GET /admin/stats/logins reads from the audit log — with auditing disabled it has nothing to query and returns an empty series, not an error. GET /admin/stats and GET /admin/stats/registrations don't depend on auditing at all; they read users directly.

Overview — GET /admin/stats

No params.

Response (200 OK)

{
  "totalUsers": 1204,
  "verifiedUsers": 1150,
  "bannedUsers": 3,
  "twoFactorEnabledUsers": 812,
  "neverLoggedInUsers": 47,
  "activeSessions": 389
}

Each field is its own COUNT(*) — six exact counts, no estimates. neverLoggedInUsers is last_login_at IS NULL — the same filter as List users → neverLoggedIn above, just counted rather than listed.

curl

curl -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/stats -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

stats, err := auth.Services().Admin.GetStats(ctx, api.GetAdminStatsInput{
    ActorID:        adminID,
    ActorSessionID: adminSessionID,
})

Client

const stats = await apiRequest("GET", "/admin/stats");

Registration trend — GET /admin/stats/registrations

Query params: from, to — both required, RFC3339. Capped at a 400-day span (invalid_input beyond that).

Response (200 OK) — one bucket per day that had at least one registration; days with zero are simply absent, not zero-filled:

{
  "registrations": [
    { "date": "2026-08-01T00:00:00Z", "count": 4 },
    { "date": "2026-08-03T00:00:00Z", "count": 1 }
  ]
}

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/stats/registrations?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

counts, err := auth.Services().Admin.GetRegistrationTrend(ctx, api.StatsRangeInput{
    ActorID: adminID, From: from, To: to,
})

Client

const { registrations } = await apiRequest(
  "GET", `/admin/stats/registrations?from=${from}&to=${to}`
);

Login activity — GET /admin/stats/logins

Query params: from, to — both required, RFC3339, same 400-day cap. userId — optional; omit for a global heatmap (every user's successful logins), set for one user's.

Counts login.success audit events only — email/password logins. OAuth (oauth.login) and admin (admin.login.success) logins are separate event types and aren't folded in.

Response (200 OK) — same shape as the registration trend, one bucket per day with at least one login:

{ "logins": [ { "date": "2026-08-01T00:00:00Z", "count": 12 } ] }

curl

# Global
curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/stats/logins?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

# One user
curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/stats/logins?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z&userId=b3f1..." \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

// Global
counts, err := auth.Services().Admin.GetLoginActivity(ctx, api.LoginActivityInput{
    ActorID: adminID, From: from, To: to,
})
// One user
counts, err = auth.Services().Admin.GetLoginActivity(ctx, api.LoginActivityInput{
    ActorID: adminID, UserID: &userID, From: from, To: to,
})

Client

const { logins } = await apiRequest(
  "GET", `/admin/stats/logins?from=${from}&to=${to}&userId=${userId}`
);

Organizations — requires WithOrganizations

Platform-admin oversight of organizations: list any org, view one regardless of the caller's own membership in it, and force a mutation an org's own owner/admin couldn't otherwise trigger from outside it. These call auth.Services().Org. That field is nil when organizations are disabled, and the routes below are not mounted.

Every mutation here publishes its own admin.org.* audit event

Distinct from the organization.* events the org's own owner/admin would publish for the same action — see Audit Logs for the full list and why the distinction matters. admin.org.deleted also snapshots the org's name/slug into the event's metadata, since the organizations row is hard-deleted and nothing else could resolve orgId back to a name afterward.

List organizations — GET /admin/orgs

Query (all optional): search (matches name or slug), createdAfter, createdBefore (RFC3339), orderBy (name default, created_at, member_count), orderDirection (asc default, desc), offset, limit (omit for default 20; 0 = unlimited; else capped at 100).

Response: { "orgs": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/orgs/count (below).

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/orgs?search=acme" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

search := "acme"
result, err := auth.Services().Org.AdminListOrgs(ctx, api.AdminListOrgsInput{
    ActorID: adminID,
    Search:  &search,
})

Client

const { orgs } = await apiRequest("GET", "/admin/orgs?search=acme");

Count organizations — GET /admin/orgs/count

Total for List organizations, split out for the same reason as Count users. Accepts search, createdAfter, createdBefore; ignores offset/limit, and validates then ignores orderBy/orderDirection.

Response: { "count": 42 }

n, err := auth.Services().Org.CountOrgs(ctx, api.AdminListOrgsInput{
    ActorID: adminID, Search: &search,
})
const { count } = await apiRequest("GET", "/admin/orgs/count?search=acme");

Get an organization — GET /admin/orgs/{orgID}

Returns the org regardless of the caller's own membership in it. Publishes admin.org.viewed.

curl

curl -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/orgs/org-123 \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

org, err := auth.Services().Org.AdminGetOrg(ctx, api.AdminGetOrgInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

const org = await apiRequest("GET", "/admin/orgs/org-123");

List an organization's members — GET /admin/orgs/{orgID}/members

Same query params as the self-service GET /auth/orgs/{orgID}/members (see Organizations), but bypasses the membership check that endpoint applies — the caller doesn't need to be a member of orgID. Publishes admin.org.viewed. The response carries no total — use GET /admin/orgs/{orgID}/members/count below.

curl

curl -H "X-GoAuth-Session: admin" https://api.myapp.com/admin/orgs/org-123/members \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

result, err := auth.Services().Org.AdminListOrgMembers(ctx, api.AdminListOrgMembersInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

const { members } = await apiRequest("GET", "/admin/orgs/org-123/members");

Count an organization's members — GET /admin/orgs/{orgID}/members/count

Total for the list above. Accepts role and search; ignores pagination and validates then ignores ordering. Does not publish an audit event — it exposes only a number, not member data.

Response: { "count": 12 }

n, err := auth.Services().Org.AdminCountOrgMembers(ctx, api.AdminListOrgMembersInput{
    OrgID: "org-123", ActorID: adminID,
})
const { count } = await apiRequest("GET", "/admin/orgs/org-123/members/count");

List a user's organizations — GET /admin/users/{id}/orgs

Every org {id} belongs to, filterable by the membership role. The self-service GET /auth/orgs only ever reads the caller's memberships; this reads someone else's, so it requires a platform admin. Responds 404 if {id} doesn't exist. Does not publish an audit event — it returns org metadata the admin can already list.

Query params: search (org name or slug), role (owner, admin or member — any other value is a 400, as on every org listing), orderBy (name default, created_at, member_count), orderDirection (asc default, desc), offset, limit (default 20, capped at 100; limit=0 means unlimited, as on the other list endpoints).

Response: { "orgs": [...], "limit": 20, "offset": 0 } — no total; use GET /admin/users/{id}/orgs/count below.

The role filter is what makes this an offboarding tool

role=owner answers the question you actually have before deleting an account: which orgs would this leave ownerless? DELETE /admin/users/{id} won't tell you, and cannot_remove_last_owner only surfaces the problem one org at a time, after you've already started. Check this first, hand ownership over with PATCH /admin/orgs/{orgID}/members/{userID}/role, then delete.

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/users/user-123/orgs?role=owner&orderBy=member_count&orderDirection=desc" \
  -H "Origin: https://myapp.com" -b admin-cookies.txt

Programmatic (Go)

role := domain.OrgRoleOwner
result, err := auth.Services().Org.AdminListUserOrgs(ctx, api.AdminListUserOrgsInput{
    ActorID: adminID, UserID: "user-123", Role: &role,
})
// result.Orgs, result.Limit, result.Offset

Role is a *domain.OrgRole — leave it nil for every role. Limit is a *int with the same nil / &0 semantics as the self-service listing (see Organizations).


Client

const { orgs } = await apiRequest("GET", "/admin/users/user-123/orgs?role=owner");

Count a user's organizations — GET /admin/users/{id}/orgs/count

Total for the list above. Accepts search and role; ignores pagination and validates then ignores ordering.

Response: { "count": 3 }

n, err := auth.Services().Org.AdminCountUserOrgs(ctx, api.AdminListUserOrgsInput{
    ActorID: adminID, UserID: "user-123",
})
const { count } = await apiRequest("GET", "/admin/users/user-123/orgs/count");

Add a member — POST /admin/orgs/{orgID}/members

Force-adds a user to an org regardless of the caller's own membership. This is the recovery path for an org whose only owner is gone (account deleted, offboarded, whatever the cause) and is otherwise unmanageable by anyone — AdminRemoveMember/AdminUpdateMemberRole alone can't fix that, since both require the target to already be a member. Publishes admin.org.member.added.

Request body: { "userId": "...", "role": "owner" | "admin" | "member" }

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/orgs/org-123/members \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt \
  -d '{"userId": "b3f1...", "role": "owner"}'

Programmatic (Go)

err := auth.Services().Org.AdminAddMember(ctx, api.AdminAddMemberInput{
    OrgID: "org-123", UserID: "b3f1...", Role: domain.OrgRoleOwner, ActorID: adminID,
})

Client

await apiRequest("POST", "/admin/orgs/org-123/members", {
  userId: "b3f1...",
  role: "owner",
});

Delete an organization — DELETE /admin/orgs/{orgID}

Force-deletes the org regardless of the caller's own membership. Publishes admin.org.deleted with the org's name/slug in the event metadata.

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/orgs/org-123 \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Org.AdminDeleteOrg(ctx, api.AdminOrgActionInput{
    OrgID: "org-123", ActorID: adminID,
})

Client

await apiRequest("DELETE", "/admin/orgs/org-123");

Remove a member / change a member's role — DELETE /admin/orgs/{orgID}/members/{userID}, PATCH /admin/orgs/{orgID}/members/{userID}/role

Force-remove or force-change a role, regardless of the caller's own membership. Removal still refuses to remove an org's last owner — that's a repository-level invariant, not an authorization check, so it holds for an admin override too. The role change is the one place this deliberately behaves differently from the self-service version: granting or revoking Owner normally requires the acting user to already be an Owner of that org (so a same-org Admin can't self-promote); AdminUpdateMemberRole skips that guard, since a platform admin acting from outside the org isn't the same threat the guard exists for. Publishes admin.org.member.removed / admin.org.member.role_changed.

Role change request body: { "role": "owner" | "admin" | "member" }

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/orgs/org-123/members/b3f1... \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X PATCH https://api.myapp.com/admin/orgs/org-123/members/b3f1.../role \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $CSRF" \
  -b admin-cookies.txt \
  -d '{"role": "owner"}'

Programmatic (Go)

err := auth.Services().Org.AdminRemoveMember(ctx, api.AdminRemoveMemberInput{
    OrgID: "org-123", UserID: "b3f1...", ActorID: adminID,
})

err = auth.Services().Org.AdminUpdateMemberRole(ctx, api.AdminUpdateMemberRoleInput{
    OrgID: "org-123", UserID: "b3f1...", NewRole: domain.OrgRoleOwner, ActorID: adminID,
})

Client

await apiRequest("DELETE", "/admin/orgs/org-123/members/b3f1...");
await apiRequest("PATCH", "/admin/orgs/org-123/members/b3f1.../role", { role: "owner" });

Platform invites — requires InviteSignup

Admin-issued, invite-only signup: an admin creates an invite for an email address, and the recipient completes registration with it via POST /auth/invite/register — covered in Authentication → Register via invite. This is a different feature from organization invites — see Organizations → Invites for those. These routes only exist when RegistrationConfig.InviteSignup: true; otherwise they 404.

An invite's status is one of pending, accepted, revoked, or expired. Admin listings derive expired from a pending row's expiresAt; token reads report expiry without writing an old snapshot back to the database.

Create an invite — POST /admin/invites

The inviting admin comes from the session (AdminID), never from the request body.

Request body: { "email": "..." }

Response (201 Created)

{
  "id": "...",
  "email": "ada@example.com",
  "createdBy": "b3f1...",
  "status": "pending",
  "expiresAt": "2026-08-16T12:00:00Z",
  "createdAt": "2026-08-09T12:00:00Z"
}

The raw invite code is emailed directly to email — it's never included in this response (rawCode is only populated internally at creation time, before the mailer sends it).

Errors

CodeStatusCause
method_disabled405InviteSignup is false
invalid_json400Malformed body or invalid email
email_failed500No mailer configured, or send failure

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/invites \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Content-Type: application/json" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt \
  -d '{"email":"ada@example.com"}'

Programmatic (Go)

invite, err := auth.Services().Invite.CreateInvite(ctx, api.CreateInviteInput{
    Email:   "ada@example.com",
    AdminID: adminID,
})

Client

const invite = await apiRequest("POST", "/admin/invites", { email: "ada@example.com" });

List invites — GET /admin/invites

Query params: offset, limit (default 20, max 100); search, status (optional); orderBy (created_at default, expires_at, email, status), orderDirection (desc default, asc).

Response (200 OK): { "invites": [...] } — no total; use GET /admin/invites/count below.

curl

curl -H "X-GoAuth-Session: admin" "https://api.myapp.com/admin/invites?status=pending&limit=20" \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

invites, err := auth.Services().Invite.ListInvites(ctx, api.ListInvitesInput{
    ActorID: adminID,
    Status:  "pending",
    Limit:   20,
})

Client

const { invites } = await apiRequest("GET", "/admin/invites?status=pending");

Count invites — GET /admin/invites/count

Total for the list, on its own call (same rationale as Count users). Accepts search and status; ignores pagination and ordering.

Response (200 OK): { "count": 42 }

n, err := auth.Services().Invite.CountInvites(ctx, api.ListInvitesInput{ActorID: adminID, Status: "pending"})
const { count } = await apiRequest("GET", "/admin/invites/count?status=pending");

Revoke an invite — DELETE /admin/invites/{id}

Soft-cancels it — sets status: "revoked". The row still exists, so it still shows up in List invites; an attempt to redeem it fails with invite_already_used instead of invite_not_found.

Response: { "message": "Invite revoked" }

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/invites/9e2c... \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Invite.RevokeInvite(ctx, api.RevokeInviteInput{InviteID: inviteID, ActorID: adminID})

Client

await apiRequest("DELETE", `/admin/invites/${inviteId}`);

Resend an invite — POST /admin/invites/{id}/resend

Only pending or expired invites can be resent. Accepted/revoked invites and a lost code-rotation race return invite_already_used without sending email. Resend cannot undo revocation or acceptance.

Generates a new code and pushes expiresAt out by another InviteTTL — the previously emailed code stops working once this succeeds, so this is "issue a fresh one," not "re-send the same email."

Response: { "message": "Invite resent" }

Errors

CodeStatusCause
invite_not_found404No invite with that ID
email_failed500Send failure

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X POST https://api.myapp.com/admin/invites/9e2c.../resend \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Invite.ResendInviteEmail(ctx, api.ResendInviteEmailInput{InviteID: inviteID, ActorID: adminID})

Client

await apiRequest("POST", `/admin/invites/${inviteId}/resend`);

Permanently delete an invite — DELETE /admin/invites/{id}/hard

Unlike revoke, this removes the row entirely — no trace left in List invites. Use revoke for the normal "this invite is no longer valid" case; reach for this only when the record itself shouldn't exist anymore (e.g. it was created for the wrong email).

Response: { "message": "Invite deleted" }

curl

CSRF=$(curl -fsS -H "X-GoAuth-Session: admin" https://api.myapp.com/auth/csrf-token -b admin-cookies.txt -c admin-cookies.txt | jq -r .token)
curl -H "X-GoAuth-Session: admin" -X DELETE https://api.myapp.com/admin/invites/9e2c.../hard \
  -H "X-CSRF-Token: $CSRF" \
  -c admin-cookies.txt \
  -H "Origin: https://myapp.com" \
  -b admin-cookies.txt

Programmatic (Go)

err := auth.Services().Invite.HardDeleteInvite(ctx, api.HardDeleteInviteInput{InviteID: inviteID, ActorID: adminID})

Client

await apiRequest("DELETE", `/admin/invites/${inviteId}/hard`);

Next

  • Audit Logs — every admin action above (and everything else) shows up here
  • Sessions — the self-service version of session listing/revocation
  • Organizations → Invites — the other kind of invite, scoped to one org
  • Routes — full param reference

On this page

AdminConfigurationCreating the first adminFrontend client setupAdmin loginAdmin login — POST /auth/admin/logincurlProgrammatic (Go)ClientListing and inspecting usersList users — GET /admin/userscurlProgrammatic (Go)ClientCount users — GET /admin/users/countcurlProgrammatic (Go)ClientGet user detail — GET /admin/users/{id}curlProgrammatic (Go)ClientCreating and managing usersCreate a user — POST /admin/userscurlProgrammatic (Go)ClientChange a user's role — PATCH /admin/users/{id}/rolecurlProgrammatic (Go)ClientBan / unban a user — PATCH /admin/users/{id}/ban, PATCH /admin/users/{id}/unbancurlProgrammatic (Go)ClientDelete a user — DELETE /admin/users/{id}curlProgrammatic (Go)ClientManaging a user's sessionsList a user's sessions — GET /admin/users/{id}/sessionscurlProgrammatic (Go)ClientRevoke one / all of a user's sessions — DELETE /admin/users/{id}/sessions/{sessionId}, DELETE /admin/users/{id}/sessionscurlProgrammatic (Go)ClientSessions across every user — GET /admin/sessionscurlProgrammatic (Go)ClientCount sessions — GET /admin/sessions/countBulk user actions — POST /admin/users/bulk/{ban,unban,delete,revoke-sessions}curlProgrammatic (Go)ClientStats and activityOverview — GET /admin/statscurlProgrammatic (Go)ClientRegistration trend — GET /admin/stats/registrationscurlProgrammatic (Go)ClientLogin activity — GET /admin/stats/loginscurlProgrammatic (Go)ClientOrganizations — requires WithOrganizationsList organizations — GET /admin/orgscurlProgrammatic (Go)ClientCount organizations — GET /admin/orgs/countGet an organization — GET /admin/orgs/{orgID}curlProgrammatic (Go)ClientList an organization's members — GET /admin/orgs/{orgID}/memberscurlProgrammatic (Go)ClientCount an organization's members — GET /admin/orgs/{orgID}/members/countList a user's organizations — GET /admin/users/{id}/orgscurlProgrammatic (Go)ClientCount a user's organizations — GET /admin/users/{id}/orgs/countAdd a member — POST /admin/orgs/{orgID}/memberscurlProgrammatic (Go)ClientDelete an organization — DELETE /admin/orgs/{orgID}curlProgrammatic (Go)ClientRemove a member / change a member's role — DELETE /admin/orgs/{orgID}/members/{userID}, PATCH /admin/orgs/{orgID}/members/{userID}/rolecurlProgrammatic (Go)ClientPlatform invites — requires InviteSignupCreate an invite — POST /admin/invitescurlProgrammatic (Go)ClientList invites — GET /admin/invitescurlProgrammatic (Go)ClientCount invites — GET /admin/invites/countRevoke an invite — DELETE /admin/invites/{id}curlProgrammatic (Go)ClientResend an invite — POST /admin/invites/{id}/resendcurlProgrammatic (Go)ClientPermanently delete an invite — DELETE /admin/invites/{id}/hardcurlProgrammatic (Go)ClientNext