This documentation guides you through integrating CZL Connect into your app.
Have a question? Leave a message on the forumApp API
CZL Connect provides a set of "App APIs" for your backend. They let an app owner use a dedicated API key to manage the app's users through CZL Connect. Unlike OAuth2 / OIDC user tokens, this API does not depend on any end-user login session. Requests are authenticated with Authorization: Bearer <api-key>.
Use cases
- Show the app's user list in your admin console, with search and pagination
- Programmatically change a user's
groupsgroup - Sign a user out by revoking all of their OAuth2 tokens for the app
- Pull the app's authorization event stream for incremental data sync
- Monitor operational metrics such as authorization counts, active users, and MAU
Differences from OAuth2 user tokens
| Aspect | OAuth2 access token | App API key |
|---|---|---|
| Identity represented | A specific user | The whole app (as a client) |
| Origin | The user completes the authorization code flow in a browser | Created or rotated manually in the app console |
| Default permissions | Data visible to that user | Management operations on all users of the app |
| Cross-user | No, limited to the user's own userinfo | Yes, within the granted scopes |
| Cross-app | No | No (one key is bound to one app) |
| Intended caller | The user's browser / app | Your backend server process |
Do not hand an App API key to a browser or client app. It is effectively a "god mode" key: anyone who holds it can manage all users of the app.
Key management
Open the app detail page and go to the "API access" tab to do the following:
| Action | Description | Confirmation |
|---|---|---|
| Create key | Generate a key after choosing scopes | Passkey |
| Copy key | Copy the full key from the list at any time; each copy is recorded in the audit log | Passkey |
| Edit key | Change the name and scopes; changes take effect on save and the key itself stays the same | Passkey |
| Rotate key | Generate a new key; the old key enters a 24-hour grace period and is disabled automatically when it ends | Passkey |
| Revoke immediately | Skip the grace period and disable the key right away | Passkey |
| Delete key | Remove it from the list; this cannot be undone | Passkey |
Key format: czlc_live_<32-character base62 suffix>. Keys are verified by SHA-256 hash lookup, and the full key is also stored encrypted with AES-GCM so it can be copied. Keys created before the copy feature launched have no encrypted copy and cannot be copied; rotate them once.
Recommended rotation flow
- Click "Rotate" in the console and get the new plaintext key
- Deploy the new key to your production configuration (environment variable / secrets manager)
- Wait until production actually starts using the new key (you can see LastUsedAt update in the key list)
- If everything works within 24 hours, the old key expires automatically and no manual action is needed
- To retire the old key sooner, click "Revoke immediately" on it
Authentication
Request header:
Authorization: Bearer czlc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If key verification fails, the API returns 401 with this body:
{
"error": "invalid_api_key",
"message": "Invalid or expired API key"
}
If the key is valid but lacks the required scope, the API returns 403:
{
"error": "insufficient_scope",
"message": "The API key lacks the required scope",
"required_any_of": ["user:read"],
"granted_scopes": ["stats:read"]
}
Scopes
| Scope | Endpoints covered |
|---|---|
user:read | GET /users, GET /users/:userId, GET /events |
user:write | PATCH /users/:userId (change enabled) |
group:write | PATCH /users/:userId (change group) |
stats:read | GET /stats |
relation:revoke | POST /users/:userId/revoke |
support:identity | POST /support/visitor-identity |
wechat:miniapp_login | POST /wechat/miniapp-login (also requires the site admin to bind this app) |
push:send | /push/* link devices, list a user's devices, send pushes (also requires push to be enabled for this app, see Push) |
email:code | POST /email-codes, POST /email-codes/verify send verification codes to any address and verify them (see Email) |
email:send | POST /emails send custom emails to this app's users |
broadcast:write | /broadcasts/* create / query / cancel broadcasts |
GET /me and GET /groups require no scope; any valid key can call them.
PATCH /users/:userId accepts both group and enabled in one request. In that case it requires both group:write and user:write, and a missing scope is rejected with 403.
Rate limits
- Limit unit: a single app, regardless of key (all keys of the same app share one bucket)
- Default quota: 600 requests per minute
- Response headers:
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Reset/Retry-After - Exceeding the limit returns
429:
{
"error": "rate_limited",
"message": "Too many requests",
"retry_after": 23
}
API endpoints
Base URL for all endpoints: https://connect.czl.net/api/v1/app
The URL does not contain an appID. The key is bound to exactly one app and the server looks the app up from the key, which also prevents you from accidentally using someone else's app ID.
GET /me
Returns metadata of the app bound to the current key, plus the key's scopes. Commonly used for a key self-check when your backend starts.
Request example:
curl -H "Authorization: Bearer czlc_live_xxx" \
https://connect.czl.net/api/v1/app/me
Response:
{
"app": {
"id": 42,
"name": "My App",
"description": "...",
"homeUrl": "https://app.example.com",
"logo": "https://...",
"enabled": true,
"isPublic": false,
"createdAt": "2026-05-01T08:00:00+08:00"
},
"apiKey": {
"id": 7,
"name": "prod backend",
"keyPrefix": "czlc_live_a3",
"scopes": ["user:read", "user:write", "group:write"],
"status": "active",
"lastUsedAt": "2026-05-18T10:23:45+08:00",
"createdAt": "2026-04-20T11:30:00+08:00"
}
}
GET /users
Lists the app's users, with pagination and filters.
Query parameters:
| Name | Type | Default | Description |
|---|---|---|---|
page | int | 1 | Page number, starting at 1 |
pageSize | int | 20 | Items per page, max 100 |
keyword | string | - | Fuzzy match on username / email |
group | string | - | Exact match on group, for example admin / t3 |
enabled | true/false | - | Filter by enabled state; omit for no filtering |
Response:
{
"items": [
{
"userId": "1001",
"username": "alice",
"email": "[email protected]",
"nickname": "",
"avatar": "https://...",
"emailVerified": true,
"group": "t2",
"enabled": true,
"globalBanned": false,
"authCount": 42,
"firstAuthAt": "2026-01-15T09:00:00+08:00",
"lastAuthAt": "2026-05-17T22:01:00+08:00",
"lastLoginAt": "2026-05-17T22:01:00+08:00",
"createdAt": "2026-01-15T09:00:00+08:00"
}
],
"total": 1234,
"page": 1,
"pageSize": 20
}
enabled is the user's enabled state within this app. globalBanned set to true means the account is banned site-wide by CZL Connect. Such a user cannot sign in regardless of enabled, and the ban cannot be lifted through this API.
GET /users/:userId
Gets one user's details in this app. The fields are the same as a single item of /users. userId is the user's numeric ID inside CZL Connect; you can get it from the /users list or from the OAuth2 userinfo response.
If the user does not exist or has not authorized this app, the API returns 404:
{
"error": "user_not_found",
"message": "The user does not exist or has not authorized this app"
}
PATCH /users/:userId
Updates the user's group or enabled state in this app.
Request body (both fields are optional, but provide at least one):
{
"group": "t3",
"enabled": true
}
Valid group values: admin / viewer / t0 ~ t5. See GET /groups for details.
When enabled is false, all of the user's OAuth2 tokens for this app are revoked at the same time (forced sign-out). The response returns the updated user view.
POST /users/:userId/revoke
Revokes all of the user's OAuth2 tokens for this app, which signs the user out. It does not delete the user's relation record in this app; the next time the user visits this app, they must go through the authorization flow to sign in again.
Response:
{
"revoked": true,
"revokedTokens": 3
}
GET /stats
Aggregate statistics for this app. Response:
{
"totalUsers": 1234,
"totalAuths": 56789,
"activeUsers30d": 432,
"newUsers30d": 87,
"dailyAuthCounts": [
{ "date": "2026-05-17", "count": 234 },
{ "date": "2026-05-16", "count": 189 }
]
}
GET /events
The app's authorization event stream, in reverse chronological order. It suits incremental data sync on your side: poll continuously and record the last id or createdAt you received.
Query parameters:
| Name | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Max items per request, up to 200 |
before | RFC3339 | - | Return only events earlier than this time |
eventType | string | - | Filter by event type, for example authorization_granted |
Response:
{
"items": [
{
"id": 99837,
"eventType": "authorization_granted",
"protocol": "oauth2",
"status": "success",
"userId": "1001",
"scope": "openid profile email",
"description": "",
"createdAt": "2026-05-17T22:01:00+08:00"
}
],
"limit": 50
}
Common event types: authorization_granted / authorization_revoked / code_issued / token_issued / token_refreshed / token_revoked.
GET /groups
Returns all available group values with their hierarchy expansion. You can use it to render a dropdown in your app.
Response:
{
"items": [
{
"code": "admin",
"description": "Administrator; can manage users and view statistics within this app",
"impliedGroups": ["t0", "t1", "t2", "t3", "t4", "t5", "viewer", "admin"]
},
{
"code": "t3",
"description": "Level 3",
"impliedGroups": ["t0", "t1", "t2", "t3"]
}
],
"note": "t0-t5 inherit lower-level groups in ascending order; admin is independent and has all permissions; viewer is independent and read-only"
}
POST /support/visitor-identity
Issues a verified visitor identity token for online support. Your backend calls it while the user is signed in, then writes the returned token into the data-identity attribute of the support widget script. Agents then see the user's profile with a "Verified" mark. For usage, see Online support.
Request body:
{ "externalId": "10086", "name": "Zhang San", "email": "[email protected]", "phone": "" }
externalIdis required and is the user identifier in your system (1-128 characters); the sameexternalIdshares one conversation across devicesname/email/phoneare optional; an invalid format returns400 invalid_request
Response:
{ "token": "svi_...", "expiresAt": "2026-11-04T12:00:00+08:00" }
The token is valid for 24 hours and can only be used with this app's support widget.
POST /wechat/miniapp-login
Signs in or signs up a CZL Connect account with the code returned by wx.login in a WeChat Mini Program. The Mini Program sends the code to your backend, and your backend calls this endpoint with its credential. The Mini Program should not call CZL Connect directly.
Prerequisite: the site admin has bound a Mini Program (AppID + AppSecret) to this app. Each app can have at most one, and a Mini Program can be bound to only one app. Only Mini Programs under CZL Connect's WeChat Open Platform account are accepted, because only their UnionIDs merge with WeChat web sign-in into the same account; that is why binding is done by the admin and not by app owners. Apps without a binding get 503 miniapp_not_configured.
- Users who have signed in to CZL Connect with WeChat before match the same account by UnionID
- New users get an account right away with a placeholder email (
emailVerified: false). They can use this app normally, but must verify a real email before authorizing other apps - The first sign-in creates the user's authorization for this app (counted toward MAU). No OAuth2 token is issued; your backend manages the Mini Program session
- A
codecan only be used once, so your backend must not calljscode2sessionitself. Theopenidneeded for subscription messages is returned in the response
Request body:
{ "code": "0a3Xxx...", "clientIp": "203.0.113.8", "userAgent": "Mozilla/5.0 ... MicroMessenger/8.0", "nickname": "" }
codeis requiredclientIp/userAgentare optional. Pass the Mini Program user's real IP and UA for sign-in history and risk checks. Only public addresses are accepted; if the IP is missing or invalid, your server's IP is recorded. This IP is asserted by your backend and is not verified by CZL Connectnicknameis optional and only used as the nickname of a new account; defaults to "微信用户"
Response:
{
"isNewUser": true,
"openid": "oAbc...",
"unionid": "oXyz...",
"user": {
"userId": "123",
"username": "wx_k2j4m5q7",
"email": "[email protected]",
"emailVerified": false,
"nickname": "微信用户",
"group": "",
"impliedGroups": ["t0"],
"enabled": true,
"globalBanned": false
}
}
The other user fields match GET /users/:userId. Use userId to link your local user to CZL Connect; impliedGroups is the group list after hierarchy expansion.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | The body is not JSON or code is missing |
| 400 | invalid_code | The code is invalid, expired or already used; call wx.login again |
| 403 | user_banned | The account is banned site-wide |
| 403 | user_disabled | The user is disabled in this app, the app is disabled, or the app is not open to new users (non-public / restricted user list) |
| 403 | wechat_risk_blocked | WeChat flagged the user as high-risk and blocked sign-in; retrying will not help |
| 403 | quota_exceeded | The app owner's account reached its plan MAU limit (existing users are unaffected) |
| 422 | unionid_unavailable | The Mini Program is not bound to the WeChat Open Platform, so no UnionID is available |
| 500 | login_failed | Internal error; retry later |
| 502 | wechat_unavailable | WeChat is temporarily unavailable; retry later |
| 503 | miniapp_not_configured | No Mini Program is bound to this app, or WeChat sign-in is not configured on this site |
| 503 | miniapp_misconfigured | WeChat rejected the bound AppID / AppSecret, or the AppSecret cannot be decrypted; the admin must re-enter it |
Unified error format
All 4xx / 5xx responses follow this format:
{
"error": "<machine_readable_code>",
"message": "<human readable text>"
}
Common error values:
| code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | The Authorization header is missing |
invalid_api_key | 401 | The key is malformed, expired, or does not exist |
insufficient_scope | 403 | The key is valid but lacks the required scope |
rate_limited | 429 | Rate limit triggered |
user_not_found | 404 | The user does not exist or has not authorized this app |
invalid_body | 400 | The request body is invalid |
empty_body | 400 | A PATCH request contained no updatable fields |
invalid_user_id | 400 | The :userId parameter is invalid |
query_failed | 500 | Internal query error |
context_missing | 500 | Internal context missing (usually a deployment issue) |
Examples
cURL
# List users
curl -H "Authorization: Bearer czlc_live_xxx" \
"https://connect.czl.net/api/v1/app/users?page=1&pageSize=20"
# Change group
curl -X PATCH -H "Authorization: Bearer czlc_live_xxx" \
-H "Content-Type: application/json" \
-d '{"group":"t3"}' \
https://connect.czl.net/api/v1/app/users/1001
# Sign a user out
curl -X POST -H "Authorization: Bearer czlc_live_xxx" \
https://connect.czl.net/api/v1/app/users/1001/revoke
Go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
const apiKey = "czlc_live_xxx"
func listUsers() {
req, _ := http.NewRequest("GET", "https://connect.czl.net/api/v1/app/users?pageSize=50", nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
_ = json.RawMessage{}
}
Node.js
const API_KEY = process.env.CZL_CONNECT_API_KEY!;
async function setGroup(userId: string, group: string) {
const r = await fetch(`https://connect.czl.net/api/v1/app/users/${userId}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ group }),
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return r.json();
}
Security recommendations
- Issue keys to servers only; never put them in frontend code, mobile apps, or public repositories
- Use different keys for different environments (production / staging / sandbox) so you can revoke precisely during an incident
- Minimize scopes: grant only the scopes you need, and do not select all by default
- If you suspect a key has leaked, revoke it immediately and create a new one instead of using the grace period
- Rotate keys proactively on a schedule (for example quarterly) to reduce the risk of long-term exposure
- Monitor your backend logs for abnormal
429/403responses