ZevAuth
Sign up

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.

POST /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

  1. 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.
  2. Serialise refreshes per connection. Two refreshes at once with the same token look exactly like a stolen token being replayed (see below).
  3. 30 days unused, they expire. Every refresh issues a fresh one, so a connection you use stays alive indefinitely.
  4. They die with the connection. Disconnecting, from either side, revokes them.
  5. They are refused while suspended. If ZevID or the product suspends your app or the connection, refresh fails with invalid_grant until it is restored.
  6. One chain per connection. Completing a new authorize for the same user and product replaces the previous refresh token.
  7. 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_reuse webhook;
  • 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:

reasonDo this
high_risk_expiredOnly ended high-risk permissions were left. Send the user through prompt=reconfirm, not a full reconnect.
grant_suspended, partner_suspended, tenant_suspendedSuspended. Keep the refresh token, show the connection as paused, and contact ZevAuth support.
pkce_mismatch, redirect_mismatch, client_mismatchA bug in your integration. Fix it before sending anyone round again.
account_unavailable, not_a_test_memberAbout 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