Delegated access to Zev products
Refresh tokens
Refresh tokens rotate on every use. Store the newest, serialise refreshes per connection, and know what reuse detection does.
/v1/partner/delegated/token curl https://api.zevauth.net/v1/partner/delegated/token \
-H "Authorization: Bearer sk_live_..." \
-H "content-type: application/json" \
-d '{ "grant_type": "refresh_token", "refresh_token": "zpr_..." }'
The response has the same shape as the code exchange: a new access token, a
new refresh token, and the connection’s current scope, targets and
gv. Replace everything you stored.
The rules
- They rotate on every use. Each refresh returns a new refresh token, and the one you sent is dead. Store the new one before you use the access token.
- Serialise refreshes per connection. Two refreshes at once with the same token look exactly like a stolen token being replayed (see below).
- 30 days unused, they expire. Every refresh issues a fresh one, so a connection you use stays alive indefinitely.
- They die with the connection. Disconnecting, from either side, revokes them.
- They are refused while suspended. If ZevID or the product suspends
your app or the connection, refresh fails with
invalid_grantuntil it is restored. - One chain per connection. Completing a new authorize for the same user and product replaces the previous refresh token.
- Never share them across services. Keep each one encrypted at rest, readable only by the service that refreshes.
Serialise refreshes
The usual way two refreshes collide is two workers, or two requests, noticing
an expired token at the same moment. Guard each connection with a lock keyed
on grant_id:
async function accessTokenFor(grantId: string) {
return withLock(`zev-refresh:${grantId}`, async () => {
const c = await connections.get(grantId);
if (c.accessTokenExpiresAt > Date.now() + 30_000) return c.accessToken; // someone else refreshed
const res = await fetch('https://api.zevauth.net/v1/partner/delegated/token', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.ZEVAUTH_SECRET_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: c.refreshToken }),
});
const body = await res.json();
if (!res.ok) throw new RefreshError(res.status, body); // see below
await connections.update(grantId, {
accessToken: body.access_token,
accessTokenExpiresAt: Date.now() + body.expires_in * 1000,
refreshToken: body.refresh_token, // the old one is dead now
scope: body.scope,
targets: body.targets,
gv: body.gv,
});
return body.access_token;
});
}
Re-read the stored token inside the lock. The request that waited usually finds a fresh token already there and does not need to refresh at all.
Reuse detection
If a refresh token that was already used is presented again, ZevID assumes it was stolen:
- every refresh token of that connection is revoked;
- the refresh fails with
invalid_grant; - you receive a
delegated_access.security.refresh_reusewebhook; - the incident is recorded against your app.
The connection itself stays in place, and access tokens already issued expire within 10 minutes. The user has to reconnect, which is one click for them. Then find out why it happened: reuse means a token was copied, or refreshes are not serialised.
When a refresh fails
Every invalid_grant means: stop using this connection’s tokens and send
the user through authorize again. Two cases differ:
reason | Do this |
|---|---|
high_risk_expired | Only ended high-risk permissions were left. Send the user through prompt=reconfirm, not a full reconnect. |
grant_suspended, partner_suspended, tenant_suspended | Suspended. Keep the refresh token, show the connection as paused, and contact ZevAuth support. |
pkce_mismatch, redirect_mismatch, client_mismatch | A bug in your integration. Fix it before sending anyone round again. |
account_unavailable, not_a_test_member | About the user’s account, not the connection. Do not send them round automatically; tell them why. |
anything else (refresh_reused, grant_revoked, test_grant_expired, …) | Mark the connection as needing reconnection and show a Reconnect button. |
Every reason is listed on Errors.
A 429 means slow down: wait for Retry-After and keep the refresh token
you have, because it was not used. See Errors
and Rate limits.
Updated at, Wednesday, October 21, 2026