---
title: Refresh tokens
description: Refresh tokens rotate on every use. Store the newest, serialise refreshes per connection, and know what reuse detection does.
---

```
POST /v1/partner/delegated/token
```

```bash
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`:

```ts
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](/delegated-access/webhooks/);
- 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.

> **Warning:**
> A rising rate of `invalid_grant` on refresh almost always means refresh
> tokens are being lost, not stored after rotation, or refreshed concurrently.
> The **Delegated access** page in your console shows the rate for the last 24
> hours.

## 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](/delegated-access/errors/).

A `429` means slow down: wait for `Retry-After` and keep the refresh token
you have, because it was not used. See [Errors](/delegated-access/errors/)
and [Rate limits](/delegated-access/rate-limits/).
