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
| 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
- Your server creates a PKCE verifier and its S256 challenge, and a random
state. - 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.
- DZBuild shows the consent screen. The merchant picks the stores and approves.
- DZBuild redirects the browser to your
redirect_uriwith acodeand yourstate. - Your server posts the code, the verifier and its client credentials to the token endpoint.
- The response carries one access token for each installed store.
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
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
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
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
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
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
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
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 page.
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
401with the codeunauthorizedand the messageInvalid or revoked API key. A verified webhook endpoint receives oneapp.uninstalledevent. - 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
| 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 page.