Delegated access to Zev products
The flow
Send the user to the authorize entry, receive the code on your callback, and exchange it for tokens. Plus how users add or remove targets later.
A connection takes one browser redirect and one server call. Both happen in your live environment, or in your test environment with your test keys.
1. Send the user to the authorize entry
/v1/partner/delegated/authorize This is a full page navigation, not a fetch. A navigation cannot carry a header, so your publishable key goes in the query string. That is safe: a publishable key already ships in your browser bundle. Never put a secret key in a URL.
| Parameter | Required | Notes |
|---|---|---|
publishable_key | yes | Your publishable key, pk_live_… (or pk_test_… to test). It selects your app; nothing else in the request can. |
resource | yes | The product, for example zevcloud. |
scope | yes, except with prompt | Space-separated. The full set you want after this flow, not just what is new. See below. |
redirect_uri | yes | Exactly one of the callbacks you registered for this product. |
state | yes | Up to 512 characters, unguessable, one per attempt. Returned to you untouched. |
code_challenge | yes | Base64url SHA-256 of your PKCE verifier. See PKCE and state. |
code_challenge_method | yes | S256. plain is refused. |
prompt | no | configure to change targets only, reconfirm to confirm high-risk permissions again. |
login_hint | no, recommended | The ZevID account id of the user you expect, when they signed in to your app with ZevID. See below. |
// Server side: create the attempt, then redirect the browser.
import { randomBytes, createHash } from 'node:crypto';
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(32).toString('base64url');
// Keep both server-side, tied to this user's session.
await saveAttempt(session.id, { state, verifier });
const url = new URL('https://api.zevauth.net/v1/partner/delegated/authorize');
url.search = new URLSearchParams({
publishable_key: process.env.ZEVAUTH_PUBLISHABLE_KEY!, // pk_live_…
resource: 'zevcloud',
scope: 'zevcloud.services.read zevcloud.deploy',
redirect_uri: 'https://app.example.com/zev/callback',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
return redirect(url.toString());
ZevAuth checks the key, that redirect_uri is registered for this product,
and that state and an S256 PKCE challenge are present. Then it redirects the
browser to ZevID. state and the PKCE challenge pass through unchanged. If
redirect_uri is not registered, there is nowhere safe to send the user, so
we show a page that says so instead of redirecting.
Tell ZevID who you expect
If your users sign in to you with ZevAuth’s Sign in with ZevID, you already know which ZevID account is theirs. Say so, in one of two ways:
- From your server (recommended): call
POST /v1/partner/delegated/authorize-urlwith your secret key and your ownuser_id. We look up their ZevID account and return the URL to send the browser to. - In the browser link: send
login_hint, their ZevID account id (thezevidentry’sproviderAccountIdunderexternalAccountson the user). It must belong to someone who signed in to this environment with ZevID.
Either way:
- the user sees the consent screen straight away, with no second sign-in;
- only that ZevID account can approve. If the browser is signed in to
another one, ZevID asks the user to switch, and if they will not, you get
error=access_denied&error_reason=account_mismatch; - at the code exchange we check that the connection came back for that
account. If it did not, we revoke it and answer
invalid_grantwithreason: account_mismatchinstead of handing you tokens. If the hint could not be recorded, the flow stops witherror=server_errorrather than carrying on unchecked; try again.
A login_hint that is not one of this environment’s ZevID sign-ins is
refused with error_reason=invalid_login_hint. user_id is only accepted on
the server call: a browser link is visible to anyone, and must not turn a user
id into an account id.
Build the URL on your server
curl https://api.zevauth.net/v1/partner/delegated/authorize-url \
-H "Authorization: Bearer sk_live_..." \
-H "content-type: application/json" \
-d '{
"resource": "zevcloud",
"scope": "zevcloud.services.read zevcloud.deploy",
"redirect_uri": "https://app.example.com/zev/callback",
"state": "…",
"code_challenge": "…",
"code_challenge_method": "S256",
"user_id": "user_123"
}'
{ "url": "https://…/v1/partner/authorize?…" }
Redirect the user’s browser to url straight away. It takes the same fields as
the browser entry, as JSON. The answer is the same whether or not user_id
exists or has signed in with ZevID: when there is no ZevID account to hint,
the URL simply has none, and ZevID signs the user in. Errors are JSON:
{ "error", "error_description", "error_reason" }.
scope is the final set
Whatever you send replaces what the connection has. Ask for everything you want to hold after this flow:
- A new permission: send the old ones plus the new one. The user sees only the new one to approve.
- Fewer permissions: send only those you keep. The rest are dropped without asking the user.
- Nothing new: the user sees a short screen to confirm their targets.
There is one connection per user per product. Authorizing again edits that connection; it never creates a second one.
2. The user consents on ZevID
ZevID signs the user in if needed, lists each permission in plain words, and asks them to pick at least one target, for example one or more ZevCloud teams. Targets the user may see but not choose are shown with the product’s reason.
Then ZevID redirects the browser straight to your redirect_uri:
https://app.example.com/zev/callback?code=zpc_…&state=…
or, if something went wrong or the user said no:
https://app.example.com/zev/callback?error=access_denied&error_description=…&error_reason=user_denied&state=…
error_reason is a machine word you can branch on; see
Errors.
3. Handle the callback
- Compare
statewith the one you stored for this session. If it does not match, or there is none, stop. Do not exchange the code. - If
erroris present, show it. Foraccess_denied, the user chose not to connect; do not retry automatically. See Errors. - Otherwise exchange the code at once, from the session that started the flow. It is valid for 60 seconds and only once.
4. Exchange the code
/v1/partner/delegated/token Server side only, with your secret key. JSON or form-encoded.
curl https://api.zevauth.net/v1/partner/delegated/token \
-H "Authorization: Bearer sk_live_..." \
-H "content-type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "zpc_...",
"code_verifier": "the verifier you stored",
"redirect_uri": "https://app.example.com/zev/callback"
}'
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "zpr_...",
"refresh_token_expires_in": 2592000,
"scope": "zevcloud.deploy zevcloud.services.read",
"grant_id": "6d1d...",
"gv": 3,
"resource": "zevcloud",
"account_id": "3b9f...",
"targets": [{ "id": "team_42", "name": "Acme Prod", "kind": "team" }],
"auth_strength": "user_consent",
"hr_exp": null,
"high_risk": [],
"tenant_mode": "live",
"grant_expires_at": null
}
The response is ZevID’s, unchanged, and may gain fields. Ignore any you do not know.
| Field | Meaning |
|---|---|
access_token | Send it to the product’s API. See Using the access token. |
refresh_token | Store it encrypted. It changes on every refresh. See Refresh tokens. |
scope | What you may use now. It can be less than you asked for. Always read it. |
grant_id | The connection. Key your storage on it; you need it to revoke. |
gv | The connection’s version. It increases on every change. |
account_id | The user’s ZevID account, the same id Sign in with ZevID gives you. Check it is the user you started the flow for. |
targets | What the user picked. Store them; the access token does not carry them. |
auth_strength, hr_exp, high_risk | High-risk permissions. See High-risk permissions. |
tenant_mode, grant_expires_at | test or live, and when a test connection ends on its own. See Test environments. |
Adding or removing targets later
Send the user through the same entry with prompt=configure. You can leave
out scope to keep the current permissions:
https://api.zevauth.net/v1/partner/delegated/authorize
?publishable_key=pk_live_…&resource=zevcloud&prompt=configure
&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256
The user sees only the target picker, with current targets ticked. Whatever they leave ticked becomes the full set; anything unticked is removed. Exchange the new code as usual and replace the targets you stored.
Updated at, Wednesday, October 21, 2026