---
title: The flow
description: 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](/delegated-access/testing/) with your test keys.

## 1. Send the user to the authorize entry

```
GET /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](/delegated-access/pkce/). |
| `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. |

```ts
// 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-url`](#build-the-url-on-your-server)
  with your secret key and your own `user_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 (the
  `zevid` entry's `providerAccountId` under `externalAccounts` on 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_grant` with
  `reason: account_mismatch` instead of handing you tokens. If the hint could
  not be recorded, the flow stops with `error=server_error` rather 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

```bash
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"
  }'
```

```json
{ "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" }`.

> **Warning:**
> Check it yourself too: after every code exchange, compare `account_id` in the
> response with the user you started the flow for. Attach a connection only to
> that user, never to whoever happens to be signed in now.

### `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`**:

```text
https://app.example.com/zev/callback?code=zpc_…&state=…
```

or, if something went wrong or the user said no:

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

## 3. Handle the callback

1. Compare `state` with the one you stored for this session. If it does not
   match, or there is none, stop. Do not exchange the code.
2. If `error` is present, show it. For `access_denied`, the user chose not
   to connect; do not retry automatically. See [Errors](/delegated-access/errors/).
3. 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

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

Server side only, with your **secret** key. JSON or form-encoded.

```bash
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"
  }'
```

```json
{
  "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](/delegated-access/access-tokens/). |
| `refresh_token` | Store it encrypted. It changes on every refresh. See [Refresh tokens](/delegated-access/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](/delegated-access/revocation/). |
| `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](/delegated-access/high-risk/). |
| `tenant_mode`, `grant_expires_at` | `test` or `live`, and when a test connection ends on its own. See [Test environments](/delegated-access/testing/). |

## 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:

```text
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.

> **Note:**
> Offer a **Manage teams** button in your app that starts this flow. The
> user's ZevID **Apps and websites** page can only *remove* targets. Adding
> one always goes through your app, or through the configure URL you gave
> ZevID when you were approved.
