Skip to main content

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​

StepRequestWho sends it
AuthorizeGET https://dzbuild.com/oauth/apps/authorizeThe merchant's browser, sent by your app
Approve or denyPOST https://dzbuild.com/oauth/apps/approve and /oauth/apps/denyThe consent form. Your app never calls these.
Token exchangePOST https://dzbuild.com/oauth/apps/tokenYour server
API callshttps://api.dzbuild.app/v1/...Your server, with the access token

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​

ParameterRequiredRule
response_typeyesAlways code.
client_idyesYour app's client id from the developer console: dzapp_ followed by 20 hex characters.
redirect_uriyesOne of the redirect URIs registered on the app, character for character. No prefix or pattern matching.
scopenoSpace-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.
stateyes1 to 1024 characters. DZBuild returns it unchanged. Tie it to the merchant's session and check it on return.
code_challengeyesBase64url SHA-256 of the verifier, without padding: exactly 43 characters of A-Z a-z 0-9 - _.
code_challenge_methodyesAlways 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.

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 &.

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.

ConditionResultReturned as
Unknown or malformed client_id, or redirect_uri not registered exactlyError page, HTTP 400Page for the merchant
App not approved and the merchant is not its developer, or app rejected or suspendedError page, HTTP 404Page for the merchant
response_type is not codeerror=unsupported_response_typeRedirect, with state when state is valid
state missing or longer than 1024 characterserror=invalid_requestRedirect, without state
code_challenge not 43 base64url characters, or code_challenge_method not S256error=invalid_requestRedirect, with state
A scope not registered on the app, not allowed for apps, or scope longer than 512 characterserror=invalid_scopeRedirect, with state
The merchant clicks Denyerror=access_deniedRedirect, with state
No store selectedError page, HTTP 400Page for the merchant
More than 10 stores selectedError page, HTTP 400Page for the merchant
A selected store is not owned by the merchant or cannot take the appError page, HTTP 403Page for the merchant
Consent request expired or already answeredError page, HTTP 400Page for the merchant

Step 4: exchange the code​

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

FieldValue
grant_typeauthorization_code
codeThe code from the redirect.
redirect_uriThe same string you sent to the authorize URL.
code_verifierThe verifier behind your code_challenge.
client_idYour client id.
client_secretYour 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_..."
}
]
}
FieldMeaning
access_tokenThe token of the first entry in stores.
token_typeAlways Bearer.
scopeThe granted scopes, space-separated. Every store in the response gets the same scopes.
store_id, install_idThe first entry in stores.
storesOne 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.

HTTPerrorerror_descriptionCause
401invalid_clientClient authentication failedMissing or wrong client_id or client_secret. With Basic credentials the response also carries WWW-Authenticate: Basic realm="dzbuild".
400unsupported_grant_typeOnly authorization_code is supportedgrant_type is not authorization_code.
400invalid_grantThe code is invalid, expired, already used, or does not match this requestUnknown, expired or used code, a code issued to another app, a different redirect_uri, or a verifier that does not match the challenge.
400invalid_grantNo approved store could be installedNo 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.
500server_errorThe code could not be checked or The install could not be completedA 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 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.codeMessageWhen
app_uninstalledThis app is no longer installed on this storeThe install is no longer active.
app_suspendedThis app has been suspended by DZBuildDZBuild suspended or rejected the app. Calls work again after the suspension is lifted.
app_not_approvedThis app is in test mode and only runs on its developer's storesThe app has not been approved yet (draft or first review) and the store does not belong to its developer.
app_plan_requiredThis app requires the ... planThe 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​

EndpointLimit
GET /oauth/apps/authorize30 requests per 300 seconds
POST /oauth/apps/approve10 requests per 600 seconds
POST /oauth/apps/token60 requests per 300 seconds
REST API with an app token120 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.

This page for AI toolsView as MarkdownOpen in ChatGPTOpen in Claude