# Environment & .env setup

This guide covers how to **safely** carry your DZBuild API credentials into your application — local dev, staging, production. Whether you're building a Node.js storefront, a Laravel/Symfony back-office, a Python data pipeline, a Go microservice, or a serverless edge function, the rules are the same.

## Before you start

* **Your store must be on an active Enterprise plan, and your key must be pilot-enrolled.** API v1 is Enterprise-only and pilot-gated in production. On any other plan, or once the Enterprise subscription expires, every call returns `403 forbidden` `"API access requires an active Enterprise plan"`. Keys generated at **Settings → API** are enrolled automatically; a key that is not enrolled returns `403 forbidden` `"API is in pilot mode; key not enrolled"` on every call, no matter how correct your setup is.
* **Always point `DZBUILD_API_BASE` at `https://api.dzbuild.app/v1`.** `dzbuild.com/api/v1/...` is only an alias for the same API: some request paths can be blocked there. The per-store rate limit (shared by all of the store's keys) and idempotency replay apply on both hosts.

## What you need to store

For a typical storefront / back-office integration:

| Variable                 | Example                             | Notes                                                                                                                                              |
| ------------------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DZBUILD_API_KEY`        | `dzpk_live_0123456789abcd.<48 hex>` | The **full bearer token**. **Treat as a password.**                                                                                                |
| `DZBUILD_API_BASE`       | `https://api.dzbuild.app/v1`        | Base URL. Use this host in prod, never `dzbuild.com/api/v1` (it's only an alias).                                                                  |
| `DZBUILD_WEBHOOK_SECRET` | 64-char lowercase hex               | Per-webhook secret returned at registration. No prefix. Every delivery to that webhook is signed with it; see [Webhook secrets](#webhook-secrets). |

### Anatomy of the bearer token

Merchants truncate this constantly, so it's worth spelling out. A platform key's bearer token is:

```
dzpk_live_<14 hex>.<48 hex>
```

73 characters, all lowercase after the prefix, with **one dot** in the middle. The `dzpk_live_…` part before the dot is the **key id** — an identifier, not a credential. `Authorization: Bearer` needs the whole 73-character string.

Get this wrong and the two layers disagree, which is a useful diagnostic:

* A token with **no dot at all** → `401 unauthorized "Invalid bearer format"`.
* A well-formed but unknown / revoked token → `401 unauthorized "Invalid or revoked API key"`.

For **public-key flow** (signups / events from a public client):

| Variable                 | Example                          | Notes                                                                 |
| ------------------------ | -------------------------------- | --------------------------------------------------------------------- |
| `DZBUILD_PUBLIC_KEY_ID`  | `dzpub_live_...`                 | Safe to ship in client code (the **id** is public, the secret is not) |
| `DZBUILD_SIGNING_SECRET` | 64-char lowercase hex, no prefix | **Server-side only** — used to HMAC-sign each request body            |

## .env files

The simplest pattern:

```
# .env (in your project root, gitignored)

DZBUILD_API_KEY=dzpk_live_0123456789abcd.0123456789abcdef0123456789abcdef0123456789abcdef

DZBUILD_API_BASE=https://api.dzbuild.app/v1

DZBUILD_WEBHOOK_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

```
# .gitignore

.env

.env.local

.env.*.local
```

Commit a **`.env.example`** file with placeholder values so other developers know which vars to set:

```
# .env.example (committed)

DZBUILD_API_KEY=dzpk_live_REPLACE_ME

DZBUILD_API_BASE=https://api.dzbuild.app/v1

DZBUILD_WEBHOOK_SECRET=REPLACE_ME_64_HEX
```

### Loading .env per language

#### Node.js / Next.js / Express

```
// next.config.js — Next.js auto-loads .env, .env.local, .env.production

// For plain Node:

import 'dotenv/config';

const key = process.env.DZBUILD_API_KEY;
```

#### Python

```
# pip install python-dotenv

from dotenv import load_dotenv

import os

load_dotenv()

key = os.environ['DZBUILD_API_KEY']
```

#### PHP / Laravel

```
// Laravel auto-loads .env

$key = env('DZBUILD_API_KEY');



// Plain PHP:

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);

$dotenv->load();

$key = $_ENV['DZBUILD_API_KEY'];
```

#### Go

```
// go get github.com/joho/godotenv

godotenv.Load()

key := os.Getenv("DZBUILD_API_KEY")
```

#### Flutter / mobile

**Don't ship the platform key in your mobile app.** Instead:

1. Your mobile app calls **your** back-end.
2. Your back-end holds the API key and proxies requests to DZBuild.

If your mobile app needs to record `signups`, send them to **your** back-end too. Every public-key request carries an HMAC signature made with the signing secret, and that secret must never ship in an app: your back-end signs and forwards the call, as in the public-key section below. Only the public key id is safe to expose. See [Public-key endpoints](https://dzbuild.com/api-docs/public-key/signups.md).

## Production environments

In production, **don't use a .env file.** Use the platform's native secret store:

### Vercel / Netlify / Cloudflare Pages

```
Project → Settings → Environment Variables

   DZBUILD_API_KEY = dzpk_live_...

   DZBUILD_API_BASE = https://api.dzbuild.app/v1

   DZBUILD_WEBHOOK_SECRET = <64-char lowercase hex>
```

Mark prod-only vars as "Production" scope. Mark staging vars as "Preview" or a separate environment.

### Cloudflare Workers

```
wrangler secret put DZBUILD_API_KEY

# (paste the value when prompted)
```

Available in the worker as `env.DZBUILD_API_KEY` (with `[vars]` declared in `wrangler.toml`).

### AWS Lambda / API Gateway

Use **AWS Secrets Manager** or **Parameter Store**:

```
import boto3, json

secret = json.loads(

    boto3.client('secretsmanager').get_secret_value(SecretId='dzbuild/prod')['SecretString']

)

key = secret['DZBUILD_API_KEY']
```

Don't store the key in Lambda env vars in plaintext (they appear in CloudTrail logs). Reference the secret manager.

### Docker / Docker Compose

```
# docker-compose.yml

services:

  app:

    image: yourapp:latest

    env_file:

      - .env.production    # NOT committed

    environment:

      - NODE_ENV=production
```

For Kubernetes, use a `Secret`:

```
apiVersion: v1

kind: Secret

metadata:

  name: dzbuild-creds

type: Opaque

stringData:

  DZBUILD_API_KEY: dzpk_live_...

  DZBUILD_WEBHOOK_SECRET: <64-char lowercase hex>
```

Then reference in the deployment:

```
envFrom:

  - secretRef:

      name: dzbuild-creds
```

### GitHub Actions

```
# .github/workflows/deploy.yml

env:

  DZBUILD_API_KEY: ${{ secrets.DZBUILD_API_KEY }}
```

Set the secret in **Repo → Settings → Secrets and variables → Actions**.

## Development vs production keys

**Always mint two separate keys:**

* One named `dev` or `staging` — used in `.env.local` / dev environments
* One named `production` — used only in your real prod deployment

If your dev key leaks, you only burn the dev key. Production data stays intact.

### How you actually get a key

The store owner generates keys in the merchant dashboard at **Settings → API** (`/dashboard/api`). The store must be on an active Enterprise plan, and it holds up to 3 active keys.

1. **Your first key** comes from that page: click **Generate API key**, name it after the environment it will live in (`myapp-dev`, `myapp-prod`) and copy the bearer token, which is shown only once. Keys made there are already enrolled in the pilot.

2. **Further keys** you can mint yourself from an existing key:

   ```
   curl -X POST 'https://api.dzbuild.app/v1/keys' \

     -H "Authorization: Bearer $DZBUILD_API_KEY" \

     -H "Content-Type: application/json" \

     -H "Idempotency-Key: $(uuidgen)" \

     -d '{"type":"platform","name":"myapp-dev"}'

   # HTTP 200 → { "data": { "key_id", "bearer_token", "signing_secret", "note" } }
   ```

   The new key inherits the calling key's rate-limit tier and pilot flag verbatim.

**Scopes are not selectable.** `POST /v1/keys` accepts only `type` and `name`. A platform key made at **Settings → API** gets the full default set: `store:read`, `store:write`, `products:read`, `products:write`, `orders:read`, `orders:write`, `customers:read`, `landing_pages:read`, `landing_pages:write`, `promos:read`, `promos:write`, `pixels:read`, `pixels:write`, `shipping:read`, `shipping:write`, `webhooks:read`, `webhooks:write`, `usage:read`, `analytics:read`, `whatsapp:read`, `whatsapp:send`. A platform key minted with `POST /v1/keys` gets that set minus any scope the calling key lacks, never more. A public key always gets `signups:write` and `events:write`. Separation between dev and prod comes from using two different keys, not from narrowing permissions.

## Webhook secrets

When you register a webhook, the response includes a `secret`:

```
curl -X POST 'https://api.dzbuild.app/v1/webhooks' \

  -H "Authorization: Bearer $DZBUILD_API_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "url":    "https://yourapp.com/webhook",

    "events": ["order.created", "order.confirmed"]

  }'
```

Response — HTTP **200**, and only three fields:

```
{

  "data": {

    "id":     42,

    "secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",

    "note":   "Save the secret now — it is not retrievable after this response."

  },

  "meta": { "request_id": "...", "api_version": "v1" }

}
```

The `secret` is a bare 64-character lowercase hex string — there is no `dzwh_sec_` prefix. It's shown **once**; save it immediately to your secret store. If you lose it, delete the webhook and register a new one.

Verify every API v1 delivery with this secret

Each delivery to the webhook carries `X-DZ-Signature: t=<unix seconds>,v1=<hex>`, the HMAC-SHA256 of `t + "." + raw_body` keyed with `DZBUILD_WEBHOOK_SECRET`. Recompute it, compare in constant time and reject a `t` more than 300 seconds from your clock. A webhook registered before per-webhook signing was introduced keeps an older signature this secret cannot check: register that webhook again.

The recipe and code in 4 languages live in [Verifying signatures](https://dzbuild.com/api-docs/webhooks/verifying-signatures.md).

## Public-key flow (signups / events)

The public-key flow exists for narrow-scope endpoints (signup tracking, event tracking) where you don't want a platform key in the loop. Only the **public key id** is non-secret — the **signing secret** stays on your back-end.

Don't call `/v1/signups` straight from a browser

Three things break a direct browser call today:

* **No CORS.** The API answers no preflight: an `OPTIONS` request without a key gets the usual `401` and no `Access-Control-*` headers, so the browser blocks the call. The API is for server-to-server calls only.
* **`Idempotency-Key` is mandatory** on every POST, and it's easy to forget in client code.
* **`crypto.randomUUID()` is not a valid nonce.** The nonce must be exactly 32 lowercase hex characters; a dashed 36-character UUID returns `401 unauthorized "Invalid nonce format"`.

Proxy through your own back-end instead — the pattern below.

```
// Browser: talk to YOUR endpoint, never to api.dzbuild.app

await fetch('/api/track-signup', {

  method: 'POST',

  headers: { 'Content-Type': 'application/json' },

  body: JSON.stringify({ email, external_user_id: externalId })

});
```

```
// Your back-end (Node): holds the signing secret, signs and forwards.

import crypto from 'node:crypto';



const KEY_ID = process.env.DZBUILD_PUBLIC_KEY_ID;

const SECRET = process.env.DZBUILD_SIGNING_SECRET;



export async function trackSignup({ email, external_user_id }) {

  const nonce = crypto.randomBytes(16).toString('hex');   // 32 lowercase hex

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

  const body  = JSON.stringify({ email, external_user_id, source: 'web', nonce });

  const hash  = crypto.createHash('sha256').update(body).digest('hex');

  const sig   = crypto.createHmac('sha256', SECRET)

    .update(`${KEY_ID}\n${nonce}\n${ts}\n${hash}`).digest('hex');



  await fetch('https://api.dzbuild.app/v1/signups', {

    method: 'POST',

    headers: {

      'Authorization':   `DZ-Public ${KEY_ID}`,

      'X-DZ-Timestamp':  ts,

      'X-DZ-Nonce':      nonce,

      'X-DZ-Signature':  sig,

      'Idempotency-Key': nonce,

      'Content-Type':    'application/json'

    },

    body

  });

}
```

The signing secret never leaves your back-end. The public key id can be inspected by anyone — that's by design. See [Signups](https://dzbuild.com/api-docs/public-key/signups.md) for the full contract.

## Local-dev tunneling for webhooks

DZBuild webhooks need a public HTTPS URL. To test locally, use a tunnel:

```
# ngrok

ngrok http 3000



# Cloudflare Tunnel

cloudflared tunnel --url http://localhost:3000
```

Register the tunnel URL as your webhook target. A registered webhook URL cannot be edited, so when the tunnel URL changes, delete the webhook, register the new URL and store the new secret it returns (ngrok free changes URL each time; pay $8/mo for a stable subdomain).

## Rotation policy

Rotate keys:

* **Quarterly** for production keys (set a calendar reminder)
* **Immediately** if a key may have leaked (committed to git, screenshotted, sent in chat)
* **When a team member leaves** if they had access to the secret store

Rotation procedure:

1. Mint a new key with `POST /v1/keys` (see [How you actually get a key](#how-you-actually-get-a-key)). It inherits the old key's tier and pilot flag. A store holds at most 3 active keys: at the limit the call answers `400 bad_request` `"Key limit reached for this store (3 active keys). Revoke unused keys first."`, so revoke an unused key before you rotate.
2. Update your secret store / env vars to the new key.
3. Deploy. Check that the new key is in use: `GET /v1/keys` lists each key with its `last_used_at` (`GET /v1/usage` counts the whole store, not one key).
4. Once you confirm 24h of clean traffic on the new key, **revoke the old one**: `DELETE /v1/keys/{old_key_id}`.

## Common mistakes

| Mistake                               | What happens                                                | Fix                                                           |
| ------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- |
| Committed `.env` to git               | Key is now in git history forever; rotate immediately       | `git filter-repo` won't fix forks/clones; assume compromised  |
| Used the platform key in browser code | Customers can read your network tab → see and steal the key | Move to back-end / serverless function; rotate the leaked key |
| Hardcoded `dzpk_live_...` in source   | Same as above                                               | Use env vars; rotate                                          |
| Same key for dev + prod               | A dev mistake hits prod data                                | Mint two keys; never share                                    |
| Lost webhook secret                   | Can no longer verify deliveries                             | Delete the webhook, register a new one                        |
| Logged the API key in app logs        | Auditors / log shippers / anyone with log access sees it    | Redact secrets in your logger config; rotate                  |

## Quick checklist before going live

* [ ] `.env` is in `.gitignore` (and was never accidentally committed)
* <!-- -->
  Production key is named `prod` and used only in prod
* <!-- -->
  Dev key is named `dev` and only in dev/staging
* <!-- -->
  Webhook secrets stored in secret manager, not in code
* [ ] `Authorization` header is on **every** API call from your back-end
* <!-- -->
  Browser code never sees `dzpk_live_*` (only `dzpub_live_*` if you use public-key flow)
* <!-- -->
  Your webhook endpoint verifies `X-DZ-Signature` with the webhook secret and dedupes on the body's `delivery_id`
* <!-- -->
  You have a key rotation reminder on your calendar
* <!-- -->
  You have logging that does NOT capture full request bodies (could include API keys in client headers)
