# OAuth install flow

An app gets access to a store through the OAuth 2.0 authorization code flow with PKCE. The merchant approves your app on a DZBuild consent screen, your server exchanges the code for one access token per store, and each token calls the REST API for its store only.

## Endpoints[​](#endpoints "Direct link to Endpoints")

| Step            | Request                                                              | Who sends it                                  |
| --------------- | -------------------------------------------------------------------- | --------------------------------------------- |
| Authorize       | `GET https://dzbuild.com/oauth/apps/authorize`                       | The merchant's browser, sent by your app      |
| Approve or deny | `POST https://dzbuild.com/oauth/apps/approve` and `/oauth/apps/deny` | The consent form. Your app never calls these. |
| Token exchange  | `POST https://dzbuild.com/oauth/apps/token`                          | Your server                                   |
| API calls       | `https://api.dzbuild.app/v1/...`                                     | Your server, with the access token            |

## The flow[​](#the-flow "Direct link to The flow")

1. Your server creates a PKCE verifier and its S256 challenge, and a random `state`.
2. It sends the merchant to the authorize URL. A merchant who is not logged in signs in first and comes back to the same URL.
3. DZBuild shows the consent screen. The merchant picks the stores and approves.
4. DZBuild redirects the browser to your `redirect_uri` with a `code` and your `state`.
5. Your server posts the code, the verifier and its client credentials to the token endpoint.
6. The response carries one access token for each installed store.

## Step 1: send the merchant to the authorize URL[​](#step-1-send-the-merchant-to-the-authorize-url "Direct link to Step 1: send the merchant to the authorize URL")

| Parameter               | Required | Rule                                                                                                                                                                         |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `response_type`         | yes      | Always `code`.                                                                                                                                                               |
| `client_id`             | yes      | Your app's client id from the developer console: `dzapp_` followed by 20 hex characters.                                                                                     |
| `redirect_uri`          | yes      | One of the redirect URIs registered on the app, character for character. No prefix or pattern matching.                                                                      |
| `scope`                 | no       | Space-separated scopes. Every scope must be registered on the app and allowed for apps. Omitted or empty means all the scopes registered on the app. At most 512 characters. |
| `state`                 | yes      | 1 to 1024 characters. DZBuild returns it unchanged. Tie it to the merchant's session and check it on return.                                                                 |
| `code_challenge`        | yes      | Base64url SHA-256 of the verifier, without padding: exactly 43 characters of `A-Z a-z 0-9 - _`.                                                                              |
| `code_challenge_method` | yes      | Always `S256`. The `plain` method is refused.                                                                                                                                |

The code verifier is 43 to 128 characters from `A-Z a-z 0-9 - . _ ~`. Keep it on your server until the token exchange. It never goes to the browser.

PKCE in PHP:

```
<?php

function base64url(string $bytes): string

{

    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');

}



$verifier = base64url(random_bytes(32));   // 43 characters

$challenge = base64url(hash('sha256', $verifier, true));



// Keep $verifier on your server (session or database) until the token call.

$authorizeUrl = 'https://dzbuild.com/oauth/apps/authorize?' . http_build_query([

    'response_type' => 'code',

    'client_id' => 'dzapp_0123456789abcdef0123',

    'redirect_uri' => 'https://app.example.com/dzbuild/callback',

    'scope' => 'orders:read products:read',

    'state' => bin2hex(random_bytes(16)),

    'code_challenge' => $challenge,

    'code_challenge_method' => 'S256',

], '', '&', PHP_QUERY_RFC3986);
```

PKCE in Node.js:

```
const crypto = require('node:crypto');



const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters

const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');



// Keep the verifier on your server (session or database) until the token call.

const authorizeUrl = 'https://dzbuild.com/oauth/apps/authorize?' + new URLSearchParams({

  response_type: 'code',

  client_id: 'dzapp_0123456789abcdef0123',

  redirect_uri: 'https://app.example.com/dzbuild/callback',

  scope: 'orders:read products:read',

  state: crypto.randomBytes(16).toString('hex'),

  code_challenge: challenge,

  code_challenge_method: 'S256',

});
```

To test your own code, the RFC 7636 example verifier `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk` must give the challenge `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`.

## Step 2: the consent screen[​](#step-2-the-consent-screen "Direct link to Step 2: the consent screen")

The consent screen shows your app name, logo and developer name. An approved app carries a "verified by DZBuild" badge. An app that is not approved yet shows a test-mode notice, and only its developer can open it.

The merchant sees the stores they own. Team members see no stores, because an install grants a token for the whole store. A store that cannot take the app is listed with the reason: the merchant is not the owner, the app is in test mode, the store plan is below the app's minimum plan, or the app is suspended. The merchant can approve up to 10 stores at once.

The requested scopes are shown grouped by resource. The consent request stays valid for 10 minutes.

## Step 3: handle the redirect[​](#step-3-handle-the-redirect "Direct link to Step 3: handle the redirect")

On approval, DZBuild answers `302` to your redirect URI:

```
HTTP/1.1 302 Found

Location: https://app.example.com/dzbuild/callback?code=3f9a...64-hex...&state=c2a8a879c9434b775191caf5b17d846f
```

The code is 64 hex characters, valid for 10 minutes and usable once. Check that `state` matches what you stored before you use the code. If your registered redirect URI already has a query string, DZBuild appends its parameters with `&`.

### Authorize and consent errors[​](#authorize-and-consent-errors "Direct link to Authorize and consent errors")

Errors reach you in one of two ways. Before DZBuild has matched your `client_id` and `redirect_uri`, it shows an error page to the merchant and never redirects, so a wrong link cannot send codes to an unknown address. After that, it redirects to your `redirect_uri` with an `error` parameter. The checks run in the order of this table.

| Condition                                                                                      | Result                            | Returned as                                  |
| ---------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------- |
| Unknown or malformed `client_id`, or `redirect_uri` not registered exactly                     | Error page, HTTP 400              | Page for the merchant                        |
| App not approved and the merchant is not its developer, or app rejected or suspended           | Error page, HTTP 404              | Page for the merchant                        |
| `response_type` is not `code`                                                                  | `error=unsupported_response_type` | Redirect, with `state` when `state` is valid |
| `state` missing or longer than 1024 characters                                                 | `error=invalid_request`           | Redirect, without `state`                    |
| `code_challenge` not 43 base64url characters, or `code_challenge_method` not `S256`            | `error=invalid_request`           | Redirect, with `state`                       |
| A scope not registered on the app, not allowed for apps, or `scope` longer than 512 characters | `error=invalid_scope`             | Redirect, with `state`                       |
| The merchant clicks Deny                                                                       | `error=access_denied`             | Redirect, with `state`                       |
| No store selected                                                                              | Error page, HTTP 400              | Page for the merchant                        |
| More than 10 stores selected                                                                   | Error page, HTTP 400              | Page for the merchant                        |
| A selected store is not owned by the merchant or cannot take the app                           | Error page, HTTP 403              | Page for the merchant                        |
| Consent request expired or already answered                                                    | Error page, HTTP 400              | Page for the merchant                        |

## Step 4: exchange the code[​](#step-4-exchange-the-code "Direct link to Step 4: exchange the code")

Your server posts a form (`application/x-www-form-urlencoded`) to the token endpoint.

| Field           | Value                                                       |
| --------------- | ----------------------------------------------------------- |
| `grant_type`    | `authorization_code`                                        |
| `code`          | The code from the redirect.                                 |
| `redirect_uri`  | The same string you sent to the authorize URL.              |
| `code_verifier` | The verifier behind your `code_challenge`.                  |
| `client_id`     | Your client id.                                             |
| `client_secret` | Your client secret (`dzas_` followed by 48 hex characters). |

You can send `client_id` and `client_secret` as HTTP Basic credentials instead of form fields. DZBuild reads the form fields first and uses the `Authorization: Basic` header only when neither field is present. Inside Basic, form-urlencode both values as RFC 6749 section 2.3.1 requires.

Send a `User-Agent` header on this request and on every API call, for example `my-app/1.0 (+https://example.com)`. `dzbuild.com` answers a `POST` without one, this token request included, with `403` and an HTML challenge page instead of JSON. Cloudflare Workers' `fetch` and several HTTP libraries send no `User-Agent` unless you set one; `curl` sends its own.

```
curl -sS https://dzbuild.com/oauth/apps/token \

  -H "Accept: application/json" \

  --data-urlencode "grant_type=authorization_code" \

  --data-urlencode "code=$CODE" \

  --data-urlencode "redirect_uri=https://app.example.com/dzbuild/callback" \

  --data-urlencode "code_verifier=$CODE_VERIFIER" \

  --data-urlencode "client_id=$DZBUILD_CLIENT_ID" \

  --data-urlencode "client_secret=$DZBUILD_CLIENT_SECRET"
```

A successful exchange answers `200` with `Cache-Control: no-store`:

```
{

  "access_token": "dzpk_live_...",

  "token_type": "Bearer",

  "scope": "orders:read products:read",

  "store_id": 141,

  "install_id": 57,

  "stores": [

    {

      "store_id": 141,

      "store_name": "Boutique Amel",

      "install_id": 57,

      "access_token": "dzpk_live_..."

    },

    {

      "store_id": 152,

      "store_name": "Amel Kids",

      "install_id": 58,

      "access_token": "dzpk_live_..."

    }

  ]

}
```

| Field                    | Meaning                                                                                |
| ------------------------ | -------------------------------------------------------------------------------------- |
| `access_token`           | The token of the first entry in `stores`.                                              |
| `token_type`             | Always `Bearer`.                                                                       |
| `scope`                  | The granted scopes, space-separated. Every store in the response gets the same scopes. |
| `store_id`, `install_id` | The first entry in `stores`.                                                           |
| `stores`                 | One entry per installed store: `store_id`, `store_name`, `install_id`, `access_token`. |

The response has no `expires_in` and no `refresh_token`. Store every token server side, keyed by `store_id`. This per-store access token is called the install token on the other pages.

### Token errors[​](#token-errors "Direct link to Token errors")

Every error is JSON with `error` and `error_description`, and `Cache-Control: no-store`.

| HTTP | `error`                  | `error_description`                                                          | Cause                                                                                                                                          |
| ---- | ------------------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 401  | `invalid_client`         | `Client authentication failed`                                               | Missing or wrong `client_id` or `client_secret`. With Basic credentials the response also carries `WWW-Authenticate: Basic realm="dzbuild"`.   |
| 400  | `unsupported_grant_type` | `Only authorization_code is supported`                                       | `grant_type` is not `authorization_code`.                                                                                                      |
| 400  | `invalid_grant`          | `The code is invalid, expired, already used, or does not match this request` | Unknown, expired or used code, a code issued to another app, a different `redirect_uri`, or a verifier that does not match the challenge.      |
| 400  | `invalid_grant`          | `No approved store could be installed`                                       | No selected store could be installed: each one failed the final check (for example a plan below the app's minimum plan) or its install failed. |
| 500  | `server_error`           | `The code could not be checked` or `The install could not be completed`      | A platform fault. Send the merchant through the authorize step again.                                                                          |

DZBuild checks the client first, then `grant_type`, then claims the code. A claimed code is spent even when the `redirect_uri` or the verifier is wrong, so a failed exchange cannot be retried with the same code. Start a new authorize request.

## Multi-store installs[​](#multi-store-installs "Direct link to Multi-store installs")

A merchant who owns several stores can approve up to 10 of them in one consent. The token response lists each installed store in `stores` with its own `install_id` and `access_token`. A token only reaches its own store.

DZBuild checks each store again during the exchange. A store that no longer qualifies is left out, so `stores` can hold fewer stores than the merchant ticked. Read `stores`, not the consent you expected.

Running the flow again for a store that already has your app updates the same install: the `install_id` stays, the scopes become the new grant, and a new token replaces the old one. The old token stops working at once.

## Calling the API[​](#calling-the-api "Direct link to Calling the API")

Send the token as a Bearer header to `https://api.dzbuild.app/v1`. `GET /v1/whoami` needs no scope and shows what a token can do:

```
curl -sS https://api.dzbuild.app/v1/whoami \

  -H "Authorization: Bearer $DZBUILD_ACCESS_TOKEN"
```

```
{

  "data": {

    "key_id": "...",

    "store_id": 141,

    "type": "platform",

    "rate_limit_tier": "enterprise",

    "pilot": true,

    "scopes": ["orders:read", "products:read"],

    "app": {

      "app_id": 12,

      "client_id": "dzapp_0123456789abcdef0123",

      "install_id": 57

    }

  },

  "meta": {

    "request_id": "...",

    "api_version": "v1"

  }

}
```

App tokens work on every store plan. The `enterprise` tier in `whoami` is how the API labels app tokens; your app's own limits are on the [rate limits](https://dzbuild.dev/rate-limits.md) page.

## Token lifetime and revocation[​](#token-lifetime-and-revocation "Direct link to Token lifetime and revocation")

An access token has no expiry date. It works until one of these happens:

* The merchant uninstalls the app. The token is revoked and calls answer `401` with the code `unauthorized` and the message `Invalid or revoked API key`. A verified webhook endpoint receives one `app.uninstalled` event.
* You run the install flow again for the same store. The new token replaces the old one.

While the install exists, the API refuses the token with `403` in these cases:

| `error.code`        | Message                                                            | When                                                                                                      |
| ------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `app_uninstalled`   | `This app is no longer installed on this store`                    | The install is no longer active.                                                                          |
| `app_suspended`     | `This app has been suspended by DZBuild`                           | DZBuild suspended or rejected the app. Calls work again after the suspension is lifted.                   |
| `app_not_approved`  | `This app is in test mode and only runs on its developer's stores` | The app has not been approved yet (draft or first review) and the store does not belong to its developer. |
| `app_plan_required` | `This app requires the ... plan`                                   | The store plan, or an expired paid plan, is below the app's minimum plan. The message names the plan.     |

Through `api.dzbuild.app`, the gateway passes DZBuild's answer through unchanged, so these `403` codes, a `401` and a `429` reach you with the status and body described here. When the gateway cannot check a token with DZBuild, because DZBuild is unreachable or answers with a server error, it answers `502` with the code `server_error` and the message `Key lookup failed, retry shortly`. Retry after a short wait.

There is no revocation endpoint for apps in v1. Uninstalling is done by the merchant from the app page in the dashboard.

Rotating the client secret in the developer console replaces it at once. Tokens already issued keep working.

## Rate limits[​](#rate-limits "Direct link to Rate limits")

| Endpoint                    | Limit                                                                |
| --------------------------- | -------------------------------------------------------------------- |
| `GET /oauth/apps/authorize` | 30 requests per 300 seconds                                          |
| `POST /oauth/apps/approve`  | 10 requests per 600 seconds                                          |
| `POST /oauth/apps/token`    | 60 requests per 300 seconds                                          |
| REST API with an app token  | 120 requests per minute per install, before the store's shared limit |

The OAuth limits count per client, identified by IP address, browser headers and session. Stay within them: a client that goes past a limit can get `429` with a `Retry-After` header of up to 120 seconds. Send `Accept: application/json` on token calls so that the refusal comes back as JSON and not as a redirect. The API limits are on the [rate limits](https://dzbuild.dev/rate-limits.md) page.
