# Security requirements

Every app installed on a DZBuild store holds a token to a merchant's orders, customers or catalogue. These rules apply to every app, in test mode and after approval. DZBuild can suspend an app that breaks them, and a suspended app gets `403 app_suspended` on every call.

## Redirect URIs[​](#redirect-uris "Direct link to Redirect URIs")

Register 1 to 5 redirect URIs in the developer console. Each one must:

* use `https`,
* be at most 512 characters,
* carry no user name, password or `#fragment`,
* contain no `*` wildcard.

DZBuild compares the `redirect_uri` of each request with the registered list character for character. `https://app.example.com/callback` and `https://app.example.com/callback/` are two different URIs. The console accepts `https` only, so test the install flow on a `workers.dev` address or through an https tunnel and register its address.

Generate a new random `state` for every authorize request, tie it to the merchant's session, and refuse a callback whose `state` does not match.

## Client secret and access tokens[​](#client-secret-and-access-tokens "Direct link to Client secret and access tokens")

The client secret (`dzas_` followed by 48 hex characters) is shown once, when the app is created or the secret is rotated. DZBuild keeps only a hash of it. Use it on your server only: never in a browser, a mobile app, a public repository or a log line.

If the secret leaks, rotate it in the developer console. The old secret stops working at once, and access tokens already issued keep working.

Access tokens (`dzpk_live_...`) give the API access the merchant approved, with no expiry. Keep them server side, encrypted at rest, one per store. Apps have no endpoint to revoke a token. If a token leaks, ask the merchant to install your app again on that store: the new token replaces the old one at once. The merchant can also uninstall the app, which revokes it.

## Verify webhook signatures[​](#verify-webhook-signatures "Direct link to Verify webhook signatures")

Every webhook DZBuild sends to your app is signed with the app's signing secret, a 64-character hex string you can view and rotate in the console. The request carries `X-DZ-Timestamp`, `X-DZ-Event`, `X-DZ-Delivery` and:

```
X-DZ-Signature: t=1758880000,v1=5d41402abc4b2a76b9719d911017c592...
```

Compute HMAC-SHA256 with the signing secret over the timestamp, a dot and the raw request body, compare it with `v1` in constant time, and reject a request whose timestamp is more than 5 minutes old. App endpoints never receive the `X-DZ-Token` header, so the signature is the only proof that a request came from DZBuild. The [webhooks](https://dzbuild.dev/webhooks.md) page has the full scheme and code in several languages.

One signing secret covers every store that installed your app. Rotating it re-keys all of them in one step.

## Verify launch tokens[​](#verify-launch-tokens "Direct link to Verify launch tokens")

When the merchant opens your app from the DZBuild dashboard, DZBuild redirects the browser to your launch URL with a `dz_launch` query parameter. The launch URL must use `https`. The parameter is a JWT signed with HS256, using the app's signing secret: the 64-character string exactly as the console shows it.

| Claim        | Value                                                       |
| ------------ | ----------------------------------------------------------- |
| `iss`        | `dzbuild`                                                   |
| `aud`        | Your `client_id`.                                           |
| `sub`        | The id of the DZBuild user who opened the app, as a string. |
| `store_id`   | The store the app was opened from.                          |
| `install_id` | Your install on that store.                                 |
| `is_owner`   | `true` for the store owner, `false` for a team member.      |
| `iat`        | Issue time, Unix seconds.                                   |
| `exp`        | `iat` plus 300 seconds.                                     |
| `jti`        | 16 random hex characters.                                   |

Accept the token only when the signature matches, the header says `HS256`, `iss` is `dzbuild`, `aud` is your client id and `exp` has not passed. Then refuse any `jti` you have already accepted in the last 5 minutes. After reading the token, redirect to a URL without it so it stays out of browser history and logs.

A launch token proves who opened the app and from which store. It does not call the API: use the access token you stored for that `store_id` and `install_id`.

In PHP:

```
<?php

/** Claims of a valid dz_launch token, or null. $clientId is your app's client_id. */

function verifyLaunchToken(string $jwt, string $signingSecret, string $clientId): ?array

{

    $parts = explode('.', $jwt);

    if (count($parts) !== 3) {

        return null;

    }

    [$head, $body, $sig] = $parts;

    $b64 = static fn(string $s): string|false => base64_decode(strtr($s, '-_', '+/'), true);

    $expected = rtrim(strtr(base64_encode(hash_hmac('sha256', "$head.$body", $signingSecret, true)), '+/', '-_'), '=');

    if (!hash_equals($expected, $sig)) {

        return null;

    }

    $header = json_decode((string) $b64($head), true);

    $claims = json_decode((string) $b64($body), true);

    if (($header['alg'] ?? '') !== 'HS256' || !is_array($claims)) {

        return null;

    }

    $now = time();

    if (($claims['iss'] ?? '') !== 'dzbuild' || ($claims['aud'] ?? '') !== $clientId

        || !is_int($claims['exp'] ?? null) || $claims['exp'] < $now || ($claims['iat'] ?? 0) > $now + 60) {

        return null;

    }

    return $claims; // Then refuse a jti you have already seen in the last 5 minutes.

}
```

In Node.js:

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



// Claims of a valid dz_launch token, or null. clientId is your app's client_id.

function verifyLaunchToken(jwt, signingSecret, clientId) {

  const parts = String(jwt).split('.');

  if (parts.length !== 3) return null;

  const [head, body, sig] = parts;

  const expected = crypto.createHmac('sha256', signingSecret).update(`${head}.${body}`).digest();

  const given = Buffer.from(sig, 'base64url');

  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;

  const header = JSON.parse(Buffer.from(head, 'base64url').toString('utf8'));

  const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));

  const now = Math.floor(Date.now() / 1000);

  if (header.alg !== 'HS256' || claims.iss !== 'dzbuild' || claims.aud !== clientId) return null;

  if (!Number.isInteger(claims.exp) || claims.exp < now || claims.iat > now + 60) return null;

  return claims; // Then refuse a jti you have already seen in the last 5 minutes.

}
```

## Delete store data after uninstall[​](#delete-store-data-after-uninstall "Direct link to Delete store data after uninstall")

When a merchant uninstalls your app, DZBuild revokes the store's token, drops the webhook deliveries still waiting for that install, and sends one `app.uninstalled` event to your webhook URL when it is verified and active:

```
{

  "id": "evt_...",

  "event": "app.uninstalled",

  "created_at": "2026-09-26T10:15:00+01:00",

  "store_id": 141,

  "data": {

    "install_id": 57,

    "client_id": "dzapp_0123456789abcdef0123",

    "store_id": 141,

    "uninstalled_at": "2026-09-26T09:15:00+00:00"

  }

}
```

Delete the data you hold for that store within 30 days of the event: orders, customers, products and every copy of the access token. If your app has no verified webhook URL, a `401` on the store's token is your signal.

## Ask for the fewest scopes[​](#ask-for-the-fewest-scopes "Direct link to Ask for the fewest scopes")

Request only the [scopes](https://dzbuild.dev/scopes.md) your features use: no write scope on data you only read, no `customers:read` for an app that never shows a customer. DZBuild compares the requested scopes with what your listing says the app does, and the merchant sees one line per resource on the consent screen before approving.

## No scraping[​](#no-scraping "Direct link to No scraping")

Reach store data only through the REST API and webhooks. Do not script the DZBuild dashboard, sign in as the merchant, or scrape dashboard or storefront pages. Never ask a merchant for their DZBuild password or for a merchant API key.

## Answer support email[​](#answer-support-email "Direct link to Answer support email")

Register a support email address that you read, and answer merchants who write to it. The consent screen and the app page in the merchant dashboard show it. DZBuild sends review decisions to your account email, not to this address.
